C++ Based OPC UA Client/Server SDK  1.6.3.406
C++ SDK Demo Server

The C++ SDK Demo Server is a collection of examples for developing different features of an OPC UA server. It is mainly composed of the final results of the Server Getting Started lessons with instance nodes in the BuildingAutomation folder and the Unified Automation Demo address space with nodes in the Demo folder.

The complete project can be found in Examples → Server Cpp Demo.

User Authentication and User Authorization Example

In this chapter we describe how to add support for user authentication and authorization to the server. Controlling the access to nodes based on user authorization requires two main steps:

User Authentication
Identify the individual user based on username and password, certificate, or other mechanism
User Authorization
Give access to specific functionality (e.g. read, write, browse) for each node to individual users.

There are several smaller steps necessary to implement user authentication and user authorization. The given sample code for user authentication provides a lot of reusable code. However, user authorization shown in the second part of the example is highly application specific. Thus, only a simplified example is shown.

User Authentication

Files used in this example:

  • servermain.cpp
  • myservercallback.h
  • myservercallback.cpp

Let’s first look at user authentication. We will show two ways of authentication here:

  • Authenticate by Username and Password
  • authenticate by UserCertificate

For both ways, we first need to take the following steps:

  1. You need to set the callback in the OpcServer object. This happens in the file servermain.cpp after creating the OpcServer object.
  2. The Session class provides a place to store the current user context in a SessionUserContext class. This context holds the current userId, groupId, and a setting for the default permission. The SDK uses the default permission for nodes without explicit access permissions.

    UaServerApplicationCallback::createSession() creates a new instance of a UaSession, and UaServerApplicationCallback::logonSessionUser() must be implemented to authenticate the user and to store the user context in the session object. Since the helper class OpcServer is used to implement the main server entry, the callback interface OpcServerCallback must be implemented to provide this functionality. The class MyServerCallback implements the callback in the files myservercallback.h and myservercallback.cpp.

Set the callback interface:

// Set the callback for user authentication
pServer->setCallback(&serverCallback);

In the UaServerApplicationCallback::logonSessionUser we first handle Username/Password authentication:

UaStatus MyServerCallback::logonSessionUser(Session* pSession, UaUserIdentityToken* pUserIdentityToken, ServerConfig* pServerConfig)

...
else if ( pUserIdentityToken->getTokenType() == OpcUa_UserTokenType_UserName )
{
if ( bEnableUserPw == OpcUa_False )
{
// Return error if User/Password is not enabled
return OpcUa_BadIdentityTokenRejected;
}
else
{
// Check user and password and set user related information on MySession
// ++ Simplified sample code +++++++++++++++++++++++++++++++++++++++
// Implement user authentication here
// This is just a trivial example with 5 different users
if ((pUserPwToken->sUserName == "root" && pUserPwToken->sPassword == "secret") ||
(pUserPwToken->sUserName == "joe" && pUserPwToken->sPassword == "god") ||
(pUserPwToken->sUserName == "john" && pUserPwToken->sPassword == "master") ||
(pUserPwToken->sUserName == "sue" && pUserPwToken->sPassword == "curly") ||
(pUserPwToken->sUserName == "sam" && pUserPwToken->sPassword == "serious"))
{
// We know that this is a known user with a valid password
pUserContext->setIdentity(pUserIdentityToken);
pSession->setUserContext(pUserContext);
// Set the roles for the session based on the user
// This is done based on the user to role assignment configured in afterNodeManagersStarted()
UA_ASSERT(ret.isGood());
// For user root we override the mapping rules and grant full access to everything
if (pUserPwToken->sUserName == "root")
{
pUserContext->setIsRoot(true);
}
pUserContext->releaseReference();
return OpcUa_Good;
}
else if (verifyUserWithOS(pUserPwToken->sUserName, pUserPwToken->sPassword).isGood())
{
// This is a user known by the operating system
pUserContext->setIdentity(pUserIdentityToken);
pSession->setUserContext(pUserContext);
UA_ASSERT(ret.isGood());
pUserContext->releaseReference();
return OpcUa_Good;
}
else
{
return OpcUa_BadUserAccessDenied;
}
// ++ Simplified sample code +++++++++++++++++++++++++++++++++++++++
}
}

This simple example works with username and password coded in the source directly. Of course, in a real server this information will come from another source or component like a configuration file or database.

The example is base on the Role concept introduces with specification 1.04. The following Well-Known-Roles are used in the sample:

RoleName Description
Anonymous No user credentials provided.
AuthenticatedUser Any user with valid credentials.
Observer This role typically has permission to browse, read and receives events.
Operator This role typically has permission to browse, read, write, receives events and call methods.
Engineer This role typically has permission to browse, read, write configuration data e.g. setup machine parameters.
Supervisor Supervisor with read access to .
ConfigureAdmin The Role is allowed to change the non-security related configuration settings.
SecurityAdmin The Role is allowed to change security related settings e.g. receive audit events and configure certificates.
MyDemoRole Role used in DemoServer

The following users are used in the sample:

UserName Password
root secret
joe god
john master
sue curly
sam serious

The following code snippet shows the implementation for user certificate authentication:

UaStatus MyServerCallback::logonSessionUser(Session* pSession, UaUserIdentityToken* pUserIdentityToken, ServerConfig* pServerConfig)

...
else if ( pUserIdentityToken->getTokenType() == OpcUa_UserTokenType_Certificate )
{
#if OPCUA_SUPPORT_PKI
if ( bEnableCertificate == OpcUa_False || m_bCertificateTokenConfigured == OpcUa_False )
{
// Return error if CertificateToken is not enabled
return OpcUa_BadIdentityTokenRejected;
}
else
{
// Just check if the certificate it trusted - we don't map this to a known user
UaUserIdentityTokenCertificate* pUserCertToken = (UaUserIdentityTokenCertificate*)pUserIdentityToken;
// create certificate object to get name and thumbprint
// validate the user certificate against the user certificate store
UaStatus ret = validateCertificate(pUserCertToken->userCertificateData);
// user is known so we put him into the Operator group
if ( ret.isGood() )
{
// setup user context for Operator
OpcUa_UInt32 operatorRoleId = NodeManagerRoot::CreateRootNodeManager()->pServerManager()->getIdForWellKnownRole(OpcUaId_WellKnownRole_Operator);
pUserContext->addRole(operatorRoleId);
pSession->setUserContext(pUserContext);
pUserContext->releaseReference();
}
// Copy the certificate into the rejected folder
else
{
// Get count of files in the directory
UaDir dirHelper("");
OpcUa_UInt16 rejectedCertCount = dirHelper.recursiveFileCount(m_sRejectedFolder.toUtf16());
// Check number of rejected certifiates to respect the m_nRejectedCertificatesCount
if (rejectedCertCount <= m_nRejectedCertificatesCount)
{
UaString fileName = UaString("%1/%2.der")
.arg(m_sRejectedFolder)
.arg(pkiCertificate.thumbPrint().toHex());
// save certificte
pkiCertificate.toDERFile(UaDir::fromNativeSeparators(fileName.toUtf8()).toUtf16());
}
ret = OpcUa_BadIdentityTokenRejected;
}
return ret;
}
#else//OPCUA_SUPPORT_PKI
// Return error if PKI is not enabled
return OpcUa_BadIdentityTokenRejected;
#endif //OPCUA_SUPPORT_PKI
}

We configure a file store for user ceritificates and do a validation of the certificate against that store. If the certificate can not be validated, we copy it into a rejected folder.

User Authorization

The SDK provides the option for a per node configuration of access permissions. The default implementation allows configuring different access permissions for owner, group, and others separately for each node.

The following flags are available to set access permission of a node:

Name Description
Browse See referenes to and from the Node. Read attributes other than Value and RolePermissions
ReadRolePermissions Read access to the attribute RolePermissions
Writettribute Write access to the attributes other than Value, Historizing and RolePermissions.
WriteRolePermissions Write access to the attribute RolePermissions
WriteHistorizing Write access to the attribute Historizing
Read Read access to the attribute Value
Write Write access to the attribute Value
ReadHistory Read history associated with the Node
InsertHistory Insert history associated with the Node
ModifyHistory Modify history associated with the Node
DeleteHistory Delete history associated with the Node
ReceiveEvents Receive events rights
Call Execute method call rights
AddReference Allow adding references to the Node
RemoveReference Allow removing references to / from the Node
DeleteNode Allow to delete the Node
AddNode Allow to add Nodes to the Namespace.

The example for user authentication is applied to the Unified Automation Demo address space. Depending on the user, the access to nodes in Objects → Demo → 005_AccessRights is limited.

All SDK interface calls triggered by an OPC UA client service call have the Session object as parameter. If you set a user context in the previous step in logonSessionUser(), the SDK automatically checks the user context to decide whether the requested operation is allowed for the current user or not.

In the DemoServer implementation, the code to set access permission on a per node basis can be found in the method NodeManagerDemo::setPermissionsForLimitedNodes().

Sampling on Request

Overview

An example on how to update variable values is already shown in the building automation server introduced in the Server Getting Started Tutorials. This section describes a different approach.

In this tutorial, the variable value handling is set to UaVariable_Value_Cache to indicate that the value needs to be polled through readValues() if a client is interested in the latest value. The method readValues() is called by the SDK for a Read service call and value change checks for monitored items.

If a server should decide whether a value is checked for changes, the value handling of these variables can be set to UaVariable_Value_CacheIsSource | UaVariable_Value_CacheIsUpdatedOnRequest. In this case, the implementer is informed about changes in monitoring and can implement his or her own logic for checking currently monitored variables for changes. The methods readValues() and writeValues() are still called for Read and Write service calls from clients.

The following code snippets show the differences to the tutorial code and can be found in the following files

  • controllerobject.cpp
  • nmbuildingautomation.h
  • nmbuildingautomation.cpp

Alterations are marked by comments in the following form:

// SamplingOnRequestExample change begin
...
// SamplingOnRequestExample change end

Implementation

Replace the following line in the file controllerobject.cpp

// Change value handling to get read and write calls to the node manager

by this code snippet:

// Set value handling to handle sampling on request
// Change value of variable to bad status BadWaitingForInitialData
// This makes sure we do not deliver an old value before we update the cache with internal monitoring
UaDataValue badStatusValue;
badStatusValue.setStatusCode(OpcUa_BadWaitingForInitialData);
pAnalogItem->setValue(NULL, badStatusValue, OpcUa_False);

The most important change in nmbuildingautomation.h is to overwrite IOManagerUaNode::variableCacheMonitoringChanged(). This method informs the derived class that the monitoring for a variable has been changed.

In addition, the class is derived from UaThread to implement the sampling in a background worker thread. The other additions, like the method run() or the member variables, are necessary to sample the variables which are active in monitoring.

class NmBuildingAutomation :
// SamplingOnRequestExample change begin
// Added: Worker thread to execute the sampling
public UaThread,
// SamplingOnRequestExample change end
{
UA_DISABLE_COPY(NmBuildingAutomation);
public:
NmBuildingAutomation();
virtual ~NmBuildingAutomation();
....
// SamplingOnRequestExample change begin
// Added: Overwrite of function variableCacheMonitoringChanged() to get informed by NodeManagerBase
void variableCacheMonitoringChanged(UaVariableCache* pVariable, TransactionType transactionType);
// Added: Main function for worker thread used to execute the sampling
void run();
// SamplingOnRequestExample change end
...
private:
...
// SamplingOnRequestExample change begin
// Added: Member variables for internal sampling in worker thread
bool m_stopThread;
UaMutex m_mutexMonitoredVariables;
bool m_changedMonitoredVariables;
std::map<UaVariableCache*, UaVariableCache*> m_mapMonitoredVariables;
UaVariableArray m_arrayMonitoredVariables;
// SamplingOnRequestExample change end
};

The new members are initialized in the constructor of the class NmBuildingautomation():

NmBuildingAutomation::NmBuildingAutomation()
: NodeManagerBase("urn:UnifiedAutomation:CppDemoServer:BuildingAutomation", OpcUa_True),
// SamplingOnRequestExample change begins
// Initialization of new members
m_stopThread(false),
m_changedMonitoredVariables(false)
// SamplingOnRequestExample change ends
{
m_pCommIf = new BaCommunicationInterface;
...

Start the sampling worker thread in NmBuildingautomation::afterStartUp():

...
// SamplingOnRequestExample change begins
// Start worker thread
start();
// SamplingOnRequestExample change ends
return ret;
}

Stop the sampling worker thread in NmBuildingAutomation::beforeShutDown():

UaStatus NmBuildingAutomation::beforeShutDown()
{
UaStatus ret;
#if SUPPORT_Historical_Access
m_pHistoryManager->shutDown();
#endif // SUPPORT_Historical_Access
// SamplingOnRequestExample change begins
// Stop worker thread
m_stopThread = true;
// Wait for thread completion
wait();
// SamplingOnRequestExample change ends
return ret;
}

To configure the sampling in the worker thread we have to implement variableCacheMonitoringChanged(). Based on the UaVariableCache::signalCount() the variable is added to sampling when the first monitored item is created, and removed from sampling when the last monitored item is removed. UaVariableCache::getMinSamplingInterval() returns the shortest sampling interval currently used for the variable. This information is not used in this example but it may be used if variables can be sampled with different rates.

/* Overwrite of base class function to get informed by NodeManagerBase about a change in monitoring
*/
void NmBuildingAutomation::variableCacheMonitoringChanged(UaVariableCache* pVariable, TransactionType transactionType)
{
// Just make sure only handle the right variables
{
return;
}
// Get fastest requested sampling interval requested by a client
// Can be used to change polling rate to device if fastest rate changed
OpcUa_Double fastedRequestedRate = pVariable->getMinSamplingInterval();
OpcUa_ReferenceParameter(fastedRequestedRate);
// This is not used in this example
if ( (transactionType == IOManager::TransactionMonitorBegin) && (pVariable->signalCount() == 1) )
{
// The first monitored item was created for variable (pVariable)
// Lock access to variable list
UaMutexLocker lock(&m_mutexMonitoredVariables);
// Add to map and set changed flag
m_mapMonitoredVariables[pVariable] = pVariable;
m_changedMonitoredVariables = true;
// Increment reference counter for the entry in the map
pVariable->addReference();
}
else if ( (transactionType == IOManager::TransactionMonitorStop) && (pVariable->signalCount() == 0) )
{
// The last monitored item was removed for variable (pVariable)
// Lock access to variable list
UaMutexLocker lock(&m_mutexMonitoredVariables);
// Add to map and set changed flag
std::map<UaVariableCache*, UaVariableCache*>::iterator it = m_mapMonitoredVariables.find(pVariable);
if ( it != m_mapMonitoredVariables.end() )
{
m_mapMonitoredVariables.erase(it);
m_changedMonitoredVariables = true;
// Decrement reference counter since we removed the entry from the map
pVariable->releaseReference();
}
}
}

The internal sampling is implemented in the main method of the worker thread:

void NmBuildingAutomation::run()
{
UaStatus ret;
OpcUa_UInt32 i;
OpcUa_UInt32 count;
std::map<UaVariableCache*, UaVariableCache*>::iterator it;
while ( m_stopThread == false )
{
// Lock access to variable list
UaMutexLocker lock(&m_mutexMonitoredVariables);
// Check if the list was changed
if ( m_changedMonitoredVariables )
{
// Update list for sampling
// First release reference for all variables in array
count = m_arrayMonitoredVariables.length();
for ( i=0; i<count; i++ )
{
// Check if the variable is still used
it = m_mapMonitoredVariables.find((UaVariableCache*)m_arrayMonitoredVariables[i]);
if ( it == m_mapMonitoredVariables.end() )
{
// Change value of variable to bad status BadWaitingForInitialData - it is not longer used
// This makes sure we do not deliver an old value when the monitoring is activated later for this variable
UaDataValue badStatusValue;
badStatusValue.setStatusCode(OpcUa_BadWaitingForInitialData);
m_arrayMonitoredVariables[i]->setValue(NULL, badStatusValue, OpcUa_False);
}
// Decrement reference counter for the variable - we removed it from the list
m_arrayMonitoredVariables[i]->releaseReference();
}
// And clear old array
m_arrayMonitoredVariables.clear();
// Create the new array and increment reference counter for added variables
it = m_mapMonitoredVariables.begin();
m_arrayMonitoredVariables.create(m_mapMonitoredVariables.size());
count = m_arrayMonitoredVariables.length();
for ( i=0; i<count; i++ )
{
m_arrayMonitoredVariables[i] = it->first;
// Increment reference counter - it was added to the list
m_arrayMonitoredVariables[i]->addReference();
it++;
}
// Reset the change flag
m_changedMonitoredVariables = false;
}
lock.unlock();
// Check if we have anything to sample
if ( m_arrayMonitoredVariables.length() > 0 )
{
count = m_arrayMonitoredVariables.length();
// Call readValues to update variable values
ret = readValues(m_arrayMonitoredVariables, results);
if ( ret.isGood() )
{
// Update values
for ( i=0; i<count; i++ )
{
m_arrayMonitoredVariables[i]->setValue(NULL, results[i], OpcUa_True);
}
}
else
{
// Set bad status for all variables
UaDataValue badStatusValue;
badStatusValue.setStatusCode(ret.statusCode());
for ( i=0; i<count; i++ )
{
m_arrayMonitoredVariables[i]->setValue(NULL, badStatusValue, OpcUa_True);
}
}
}
}
}

Loading Address Space from UANodeSet XML File

The demo server contains an example for loading all or part of the address space from a UANodeSet XML file.

The sample code can be found in

  • servermain.cpp
  • mynodemanagernodesetxmlcreator.h
  • mynodemanagernodesetxmlcreator.cpp
  • mynodemanagernodesetxml.h
  • mynodemanagernodesetxml.cpp
  • buildingautomationxml.xml

The example XML file is located in the directory “bin” of the demo server executable. The file buildingautomationxml.xml contains the same controller objects as the building automation server introduced in the Server Getting Started Tutorials. Some of the sample code to connect the loaded variables to the controller simulation is similar to the code used in the tutorials.

The parser and the necessary classes are initialized and added with the following code in the file servermain.cpp.

// Create and initialize server object
OpcServer* pServer = new OpcServer;
pServer->setServerConfig(sConfigFileName, szAppPath);
...
// XML UANodeSet file to load
UaString sNodesetFile(UaString("%1/buildingautomationxml.xml").arg(szAppPath));
// We create our own BaseNode factory to create the user data from XML
MyBaseNodeFactory* pBaseNodeFactory = new MyBaseNodeFactory;
// We create our own NodeManager creator to instantiate our own NodeManager
MyNodeManagerNodeSetXmlCreator* pNodeManagerCreator = new MyNodeManagerNodeSetXmlCreator;
UaNodeSetXmlParserUaNode* pXmlParser = new UaNodeSetXmlParserUaNode(sNodesetFile, pNodeManagerCreator, pBaseNodeFactory, NULL);
// Add UANodeSet XML parser as module
pServer->addModule(pXmlParser);
// Start server object
ret = pServer->start();

The class MyBaseNodeFactory is derived from UaBase::BaseNodeFactory and overwrites the method UaBase::BaseNodeFactory::createVariable(). The factory creates data classes for the OPC UA nodes in the XML file. These data classes are used in a second step to create the nodes in the NodeManager. Overwriting the factory allows the creation of specialized data classes. In the example, the data class MyVariable derived from UaBase::Variable is used to parse the extension in the XML file that contains the user data for the variable.

The class MyNodeManagerNodeSetXmlCreator is used to create specialized NodeManagers for a known namespace in the XML file. In this example the special NodeManager is implemented in the class MyNodeManagerNodeSetXml. This class implements all logic necessary to initialize the controller objects and to implement data access to variable values and methods as well as event and alarm handling.

Alarm Object Handling with and without Nodes in the Address Space

The demo address space code contains sample code for the handling of alarm objects of different types. The example includes alarm objects which show up as OPC UA nodes in the address space as well as alarm objects not visible in the address space. The latter are working completely without nodes in the address space.

The sample code is contained in the class NodeManagerDemo which can be found in the files

  • demo_nodemanagerdemo.h
  • demo_nodemanagerdemo.cpp

The alarm objects are created and initialized in the method createAlarmNodes(). The objects visible in the address space are also added to the NodeManager. These objects are deleted by the NodeManager at shutdown. In addition this method creates variables used to trigger the alarm states.

The alarm objects without nodes have to be deleted in the destructor of the NodeManagerDemo.

The state of the alarm objects can be activated by writing “true” to the OffNormalAlarm variables or by assigning analog values for the level alarms ranging from 0 to 100. Values below 30 trigger a low alarm. Values above 70 trigger a high alarm. To modify the alarm states on write, the method IOManagerUaNode::afterSetAttributeValue() is overwritten in NodeManagerDemo. This method contains the sample code for changing the alarm states.

To handle alarm acknowledgement the methods EventManagerUaNode::OnAcknowledge and EventManagerUaNode::OnConfirm are overwritten in the class NodeManagerDemo. This works only for alarm objects visible in the address space. To handle the Acknowledge and AddComment methods for alarm objects without nodes, the methods getMethodHandle() and beginCall() are implemented to provide the MethodManager functionality for these alarm objects.

Structured Data Type Example

The SDK contains two utility classes for the handling of structured data types: UaStructureDefinition to describe a structured data type and UaGenericStructureValue to serialize and deserialize the data.

The demo address space code contains sample code for the handling of structured data types. A variable with a nested structure can be found in Objects → BuildingAutomation → ControllerConfigurations. This variable contains a structure with two arrays of structures with the configuration parameters for all controllers.

The sample code can be found in the file nmbuildingautomation.cpp

The enumeration, structured data type nodes, and dictionary are created in the function NmBuildingAutomation::createTypeNodes().

The structure value is filled up in the function NmBuildingAutomation::readValues().

Event History Access Example

The demo address space code contains sample code for the handling of historical access for events. The area object providing event history can be found in Objects → Server → AreaAirConditioner. This object event notifier attribute indicates availablity of event history.

The sample code can be found in the files

  • nmbuildingautomation.cpp
  • historymanagercache.h
  • historymanagercache.cpp

The internal event monitored item is created in the function NmBuildingAutomation::afterStartUp().

The class HistoryManagerCache implements all data and event history functions and data and event historizing. The events and data changes are monitored with internal monitored items and are stored in memory.

The sample code expects that the nodes providing event history are managed by the NodeManager that also manages the HistoryManager. This is necessary to forward HistoryRead requests for a node to the right HistoryManager.

If a node is managed by another NodeManager, the responsible HistoryManager must be registered for the a UaNode or for the whole NodeManager. The class NodeManagerBase provides two options. The first is to use NodeManagerBase::setHistoryManager() to set the default HistoryManager for all UaNodes in the NodeManager. The second option is to set a HistoryManager for single UaNodes using the methods NodeManagerBase::setHistoryManagerForUaNode() and NodeManagerBase::removeHistoryManagerForUaNode().

The following sample code shows how to handle event history for the Server object managed by NodeManagerRoot.

HistoryManagerCache* pMyHistoryManager = NULL;
// Get history manager
// Get NodeManagerRoot and Server object pointers
UaObjectServer* pServerNode = pNodeManagerRoot->pServerObject();
// Create event monitored item for Server object to historize events in HistoryManager
// See HistoryManagerCache::addEventNotifierToHistorize for sample code
// Update the EventNotifier attribute of the Server object with HistoryRead flag
pServerNode->setEventNotifier(OpcUa_EventNotifiers_SubscribeToEvents | OpcUa_EventNotifiers_HistoryRead );
// Set the HistoryManager interface for the Server object
// The NodeManagerRoot manages the Server object node and needs to know that
// an external HistoryManager is now responsible for processing HistoryRead for Events
// for the Server object
pNodeManagerRoot->setHistoryManagerForUaNode(OpcUaId_Server, pMyHistoryManager);

Model Change Event Example

The demo address space code contains sample code for dynamic creation of nodes including model change event. The folder with the functionality can be found in Objects → Demo → 008_DynamicNodes. This folder contains two methods for node creation and deletion, a NodeVersion property and the dynamic node. Only nodes with a NodeVersion property are allowed to fire specific model change events of type GeneralModelChangeEventType. This event indicates the changed nodes with a NodeVersion property and the changes (node added/deleted, reference added/deleted). Since the new node does not have a NodeVersion property, only the added reference from the node (008_DynamicNodes) with NodeVersion property has a new reference contained in the model change event.

Changes of nodes without NodeVersion property can be indicated by using the BaseModelChangeEvent. But this event does not indicate which part of the address space has changed.

The sample code can be found in the files

  • demo_nodemanagerdemo.cpp
  • demo_nodemanagerdemo.h

During start-up of the server the GeneralModelChangeEventType must be created in the event type hierarchy. This can be done with the following code contained in NodeManagerDemo::afterStartUp().

The full sample code for the generation of the model change event can be found in the methods NodeManagerDemo::Demo_DynamicNodes_CreateDynamicNode() and NodeManagerDemo::Demo_DynamicNodes_DeleteDynamicNode(). The following code is the essential part of the event generation.

// Prepare model change structure event data
changes.create(1);
// Only a node with a NodeVersion property (DemoId_Demo_DynamicNodes)
// indicates its changes -> we added a reference to this node
change.setModelChangeStructureDataType(
pDynamicNodes->nodeId(),
pDynamicNodes->typeDefinitionId(),
OpcUa_ModelChangeStructureVerbMask_ReferenceAdded);
change.copyTo(&changes[0]);
// Prepare model change event
eventData.setMessage(UaLocalizedText("", "Node added"));
eventData.setChanges(changes);
// Fire model change event
fireEvent(&eventData);

The sample code creates only one change. If more than one node with a NodeVersion property is affected, all changes should be combined in one event.