1. Introduction
Hello and welcome to OPC UA!
This is the Prosys OPC UA SDK for Delphi tutorial for server application development. With this quick introduction you should be able to grab the basic ideas behind the Prosys OPC UA SDK for Delphi. You might like to take a look at the Client Tutorial as well, but it is not a requirement.
Note that this tutorial assumes that you are already familiar with the basic concepts of OPC UA communications, although you can get started without much prior knowledge.
For a full reference on OPC UA communications, we recommend the book OPC Unified Architecture by Mahnke, Leitner and Damm (Springer-Verlag, 2009, ISBN 978-3-540-68898-3).
2. Sample Applications
The SDK contains a sample server application, ProsysOPC.UaSampleServer
for Delphi and UaCppSampleServer
for C++. This tutorial will refer to both the Delphi and C++ samples when it explains the main tasks of an OPC UA server.
3. TUaServer Component
TUaServer
is the main component that you will be working with in your OPC UA server application. It defines a full OPC UA server implementation, which you can configure to your needs via the component properties. Alternatively, you can inherit your own version of the class in case you need to modify the default behaviour or you otherwise prefer to configure your server that way. We will describe how you can simply instantiate the TUaServer
and define your server functionality by customizing the service managers that perform specific functionality in the server.
You can add a new TUaServer
component either at design time in the form designer (see TUaSampleServerForm
) or at run time as follows:
UaServer := TUaServer.Create(nil);
where Owner
is either nil
or another component or form that will be responsible of freeing the component, as usual with Delphi components.
You will typically instantiate just one UaServer in your application.
3.1. Application Identity
All OPC UA applications must define some characteristics of themselves. This information is communicated to other applications via the OPC UA protocol when the applications are connected.
For secure communications, the applications must also define an Application Instance Certificate. The certificates are used to authenticate OPC UA applications to each other. They are also used to provide the asymmetric keys (together with the respective Private Keys) which the applications use to sign and encrypt data. Depending on the selected security level, servers may only accept connections from clients that they trust.
3.1.1. Application Description
The characteristics of an OPC UA application are defined in the ApplicationDescription, which is part of the ApplicationIdentity. The ApplicationDescription must be initialized in code as follows:
UaServer.ApplicationIdentity.ApplicationDescription := TUaApplicationDescription.Builder
.ApplicationUri('urn:localhost:UA:DelphiSampleServer')
.ProductUri('urn:prosysopc.com:UA:DelphiSampleServer')
.ApplicationName('DelphiSampleServer@localhost')
.ApplicationType(atServer).Build;
where
ApplicationUri is a unique identifier for each running instance.
ProductUri is used to identify your product and should therefore be the same for all instances. It should refer to your own domain, for example, to ensure that it is globally unique.
ApplicationName is used in user interfaces as a name identifier for each application instance.
ApplicationType defines the type of the OPC UA application, which in this case is Client.
Since the ApplicationName and ApplicationUri should be unique for each instance (i.e. installation), it is a good habit to include the hostname of the computer in which the application is running in both of them. The SDK supports this by automatically converting localhost
to the actual hostname of the computer (e.g. 'myhost').
The URIs must be valid identifiers, i.e. they must begin with a scheme, such as ‘urn:’ and may not contain any space characters. There are some applications in the market, which use invalid URIs and may therefore cause some errors or warnings with your application. |
3.1.2. Application Instance Certificate
In addition to ApplicationDescription, you have to define the Application Instance Certificate. By default, the applications can create their own certificate with the LoadOrCreateCertificate
method:
UaServer.ApplicationIdentity.Organisation := 'Prosys OPC Ltd';
UaServer.ApplicationIdentity.LoadOrCreateCertificate({EnableRenew} false);
Organisation will be used for the Subject of the certificate, in addition to the ApplicationName that is defined in the ApplicationDescription. ApplicationUri from the ApplicationDescription will be added in the SubjectAlternativeName of the certificate.
LoadOrCreateCertificate creates a self-signed certificate and a corresponding private key for the application the first time it is called. It also stores them to the files '<ApplicationName>.der' and '<ApplicationName>.pem', respectively, in the CertificateStore (see below). On following runs, the application will load the certificate and the private key from these files. The Boolean parameter for LoadOrCreateCertificate determines whether the certificate will be automatically renewed when it expires.
As the name Application Instance Certificate says, the certificate is used to identify each application instance. That means that every installation on every computer must have a unique certificate. To ensure this, the certificate also contains the ApplicationUri, which must match the one defined in the ApplicationDescription. If you change the hostname of the computer, the certificate must also be recreated.
The self-signed certificates are useful to ensure that each application has a certificate by default. However, in real production environments, you should consider establishing a proper Certificate Authority (CA), which signs all application instance certificates. You can then manage them centrally and the applications can also trust automatically to each certificate that is created by the well-known CA. OPC UA also defines a product type, Global Discovery Server (GDS), which is designed to be such a CA. But at the moment, there are not many actual GDS products available in the market yet.
Note that if some other application gets the same key pair, it can pretend to be the same OPC UA application. The private key should be kept safe in order to reliably verify the identity of this application. Additionally, you may secure the usage of the private key with a password that is required to open it for use (but you need to add that in clear text in your application code or prompt it from the user). The certificate is public and can be distributed and stored freely anywhere withouut affecting the security of the applications. |
3.2. Validating Application Instance Certificates
As explained in the previous chapter, OPC UA applications define their identity with an ApplicationDescription and Application Instance Certificate.
Upon a secure connection, the applications verify each others' identity and they can also determine which applications they accept the connection with. This is done by validating the Application Instance Certificate.
By default, the SDK validates the certificate of the other application for basic validity, when the connection is made. This includes checking the validity period and that the ApplicationUri defined in the SubjectAlternativeNames field of the certificate matches the one in the ApplicationDescription provided at the connection phase.
3.2.1. CertificateStore
To define which applications are allowed to have a connection, they use a Certificate Store, where the known Application Instance Certificates are stored. The Certificate Store includes a folder for trusted and rejected certificates and it may also include a folder for revoked certificates. The Certificate Store is in practice a folder on disk. The actual location is defined by the RootFolder property, which is by default 'PKI':
UaServer.CertificateStore.RootFolder := 'PKI';
There are also properties for configuring the locations of the trusted
, rejected
and revoked
certificates, but these are also defined by default so you don’t need to touch them usually. The application’s own keys (that were explained above) are stored in the private
folder.
By default, a previously unseen certificate is not trusted and it is stored in the rejected folder. You can move it to the trusted folder to make it - and the respective application - trusted.
Certificates that are signed by a trusted CA, can be trusted automatically.
3.2.2. OnValidate
Server applications cannot prompt the user at the connection phase to ask whether a new certificate can be trusted. But if necessary, the validation can still be customized. For example to accept all certificates even if the ApplicationUri does not match the one in the ApplicationDescription (do not require cvcApplicationUri
or cvcApplicationUriValid
):
procedure TUaSampleServerForm.UaServerValidateCertificate(Sender: TObject;
Certificate: TUaCertificate; ApplicationDescription:
IUaApplicationDescription; PassedChecks: TUaCertificateValidationChecks; var
Result: TUaCertificateValidationResult);
begin
// Ignore the possible ApplicationURI mismatch
if [cvcTrusted, cvcValidity, cvcSignature] <= PassedChecks then
Result := cvrAcceptPermanently
else
Result := cvrReject;
end;
If the connection is made with MessageSecurityMode None, all security validation, including verification of the certificates is ignored. |
3.3. Server Endpoints
The server endpoints are the connection points to which the client applications can connect. Each endpoint consists of an URL address and security settings.
The server defines which endpoints are available and the client decides which of these it will use. TUaClient
in the SDK, for example, will pick the matching endpoint automatically according to the defined security settings (MessageSecurityMode, SecurityPolicies & SecurityPolicyStrategy).
3.3.1. Endpoint URLs
First, we define the endpoint URL(s) by specifying a port and a server name for the UA TCP transport protocol, which is currently the only supported protocol.
UaServer.Port := 62620;
UaServer.ServerName := '';
The properties will define the endpoint URLs of the server as follows
<Protocol>://<Hostname>:<Port>/<ServerName>
An endpoint URL is always defined using the actual hostname. The ServerName is optional, and in the sample server, it is not defined. In that case the ending '/' is also left out.
3.3.2. BindAddresses
You can also control the IP addresses that the server is bound to via the BindAddresses
:
UaServer.BindAddresses.Add(Address);
If you add BindAddress(es), the server will listen only to the defined interfaces. By default, it will listen to all interfaces in the computer.
3.3.3. Security Modes
The server can support different security modes, which consist of message security modes and security policies. They can be configured simply like this:
UaServer.MessageSecurityModes := AllMessageSecurityModes;
UaServer.SecurityPolicies := AllSecurityPolicies;
AllMessageSecurityModes
and AllSecurityPolicies
are defined in the SDK and follow the current best practices. They are also the default values of and do not have to be explicitly defined. The current definitions are as follows:
TUaMessageSecurityMode = (
msmNone = 0,
msmSign = 1,
msmSignAndEncrypt = 2
);
TUaMessageSecurityModeSet = set of TUaMessageSecurityMode;
AllMessageSecurityModes: TUaMessageSecurityModeSet = [msmNone..msmSignAndEncrypt];
TUaSecurityPolicy = (spBasic128Rsa15, spBasic256, spAes128Sha256RsaOaep, spBasic256Sha256, spAes256Sha256RsaPss);
TUaSecurityPolicySet = set of TUaSecurityPolicy;
//: All OPC UA security policies (including deprecated ones)
AllSecurityPolicies: TUaSecurityPolicySet = [spBasic128Rsa15..spAes256Sha256RsaPss];
//: All non-deprecated OPC UA security policies defined in OPC UA version 1.04
AllSecurityPolicies104: TUaSecurityPolicySet = [spAes128Sha256RsaOaep..spAes256Sha256RsaPss];
3.3.4. User Authentication
You must define one or more User Token Policies that the server supports. When activating a session, a client shall send a user token that matches one of the supported token policies. A user token policy is a combination of a User Token Type (Anonymous, UserName and X509 Certificate are the alternatives) and a Security Policy. By default, UaServer is supporting Anonymous tokens and Basic256 and Basic256Sha256 security policies. You can configure these using the properties as follows:
UaServer.UserTokenTypes := [uttAnonymous, uttUserName, uttCertificate];
UaServer.UserTokenSecurityPolicies := [spBasic256, spBasic256Sha256]; // The default
If you support other user token types than anonymous, you should also define the user validation event:
UaServer.SessionManager.OnValidateUser := ValidateUser;
The return value determines whether the user is allowed access to the server:
function TUaSampleServerForm.ValidateUser(Sender: TObject; Session:
IUaSession; UserIdentity: IUaUserIdentity): Boolean;
begin
Result := False;
if UserIdentity.UserTokenType = uttAnonymous then
Result := True
// Accept all certificates - just as a sample!
else if UserIdentity.UserTokenType = uttCertificate then
Result := True
else if UserIdentity.UserTokenType = uttUserName then
if ((UserIdentity.UserName = 'opcua') and (UserIdentity.Password = 'opcua')) then
Result := True;
end;
If the event is not defined, all users are accepted.
4. Server Address Space
OPC UA servers provide the information that they have available from the OPC UA Server Address Space. This helps the client applications to locate all data that they need from the server, even when they don’t have any prior knowledge about it.
You can use Prosys OPC UA Client application or |
4.1. Nodes
The OPC UA Server Address Space is composed of Nodes, which define all data and metadata of the server. The available Node Classes are Object, Variable, Method, ObjectType, VariableType, DataType, ReferenceType and View.
Objects are used for defining structures, whereas Variables are used for defining parameter and measurement data. They are both classified as Instances and they must always have a TypeDefinition, which refers to an ObjectType or VariableType, respectively.
The various types (ObjectType, VariableType, DataType and ReferenceType) are used to define the metadata. They are also the basis of OPC UA Information Models, which typically define standard or custom types, and help identifying predefined structures available in the server.
When Objects, Variables and Methods are used as part of type declarations, they are called Instance Declarations.
4.1.1. NodeId
The OPC UA client applications identify the nodes in the OPC UA server using Node Identifiers (NodeIds). They are used when the client sends any service requests related to nodes to the server, for example Read or Write. If the client application does not have the NodeId of a certain node available, it can browse the server address space to find it.
4.1.2. Namespace
OPC UA uses the concept of Namespace to help defining unique names and identifiers for the Nodes. BrowseName and NodeId attributes are defined with a reference to a Namespace. Different organizations can assign Namespaces for themselves and then define Nodes in these Namespaces to ensure that they are uniquely identified.
NamespaceUri
Each Namespace is identified by a NamespaceUri, which is typically of the format 'http://<organization>/<NamespaceIdentifier>'. For example, the NamespaceUri of the standard OPC UA Namespace is 'http://opcfoundation.org/UA'.
NamespaceIndex
In OPC UA communication, applications typically refer to the Namespaces with a NamespaceIndex, instead of NamespaceUri. This is done simply to improve the performance of communication. The NamespaceIndex refers to the index of the Namespace in the NamespaceTable of the OPC UA server in question. The servers expose the contents of the table via the NamespaceArray variable, which is a standard component of the Server object in the Address Space.
Unfortunately, the NamespaceIndex is troublesome in practice. It can obviously be different in different servers for the same Namespace. In some cases, it can even change within the same server. So the NamespaceIndex always requires knowledge about the NamespaceTable in question.
TUaNamespace, TUaNodeId and TUaQualifiedName
In the OPC UA SDK for Delphi, we are using TUaNamespace
objects to define the Namespaces. The main property of TUaNamespace
is NamespaceUri
.
TUaNodeId
and TUaQualifiedName
(used by the BrowseName
attribute of every Node) always refer to a TUaNamespace
using the Namespace
property. This differs from most of the other toolkits and applications, which define NodeIds and QualifiedNames mainly with NamespaceIndex. Therefore, we use a reference to the Namespace instead of storing the NamespaceIndex in TUaNodeId
or TUaQualifiedName
.
However, should you need to find the NamespaceIndex of a certain Namespace, you can always look it up from the NamespaceTable in question. For example with either one of:
Index := UaServer.NamespaceTable.IndexOf(Namespace);
Index := UaServer.NamespaceTable.IndexOfUri(NamespaceUri);
4.2. Node Managers
The Server Address Space is defined in practice by TUaNodeManager
objects which are used to manage the Nodes of the server.
Each node manager is assigned a unique NamespaceUri in the server. The NamespaceTable is then composed of the NamespaceUris of each node manager. Although the Namespaces have their unique URIs, they are often referred to with NamespaceIndexes, which then refer to their position in the NamespaceTable.
NOTE: The NamespaceIndex of a certain namespace may be different in different servers. The index itself is not a reliable reference to the Namespace as declared above.
4.3. Standard Node Managers
By default, TUaServer
always contains a root node manager, TUaNodeManagerRoot
. It handles the standard OPC UA server address space, i.e. nodes located in the OPC UA standard namespace (NamespaceUri='http://opcfoundation.org/UA/'). It includes the main folders (Objects, Types and Views) and the standard UA types. It also manages the Server object, which is used to publish server status, capabilities and diagnostic information to OPC UA clients. The OPC UA standard namespace is always assigned with NamespaceIndex=0.
In addition, the UaServer
also contains an internal NodeManagerUaServer
which manages server specific data and is always assigned with NamespaceIndex = 1. The NamespaceUri of this node manager equals to the ApplicationUri of the server (defined in Application Description).
4.4. Your Own Node Managers
In order to be able to add your own nodes into the address space of your server, you must define your own node manager(s) with your own namespace(s). You have a couple of alternatives for choosing which node manager to create.
Instead of defining a single node manager for your data, you can always decide to split your node hierarchy and manage different parts of it with different node managers. |
It is a good convention to define types and instances in separate namespaces. The OPC Foundation defines several companion specifications (i.e. different domain-specific information models) and their respective types can be loaded into the server in their respective namespaces. See Information Modeling for more about the information models. |
The following examples are found from the ProsysOPC.UaSampleServer
and UaCppSampleServer
applications that you will find from your Documents\ProsysOPC\Sentrol\Samples
folder.
4.4.1. NodeManager for Types
If you followed the instruction at Information Modeling, you learned that the types are often loaded from Nodeset files. This will also create a new NodeManager to the server and you can get an access to that using the NamespaceUri of the model, for example as follows.
procedure TUaSampleServerForm.CreateAddressSpace;
var
MyDevice: TUaObject;
MyObjectsFolder: TUaFolderType;
begin
// *** Load custom Types from SampleNamespace.xml to MyTypesNodeManager
UaServer.AddressSpace.LoadModel('SampleNamespace.xml');
MyTypesNodeManager := UaServer.AddressSpace.GetNodeManager(
'http://www.prosysopc.com/UA/Delphi/SampleNamespace')
as TUaNodeManagerUaNode;
void __fastcall TUaCppSampleServerForm::CreateAddressSpace()
{
// *** Load custom Types from SampleNamespace.xml to MyTypesNodeManager
UaServer->AddressSpace->LoadModel("SampleNamespace.xml");
MyTypesNodeManager = (TUaNodeManagerUaNode *) UaServer->AddressSpace->GetNodeManager(
"http://www.prosysopc.com/UA/Delphi/SampleNamespace");
4.4.2. NodeManagerUaNode
The instances, i.e. Objects and Variables, that model the actual things in your system should typically go to their own namespaces and NodeManagers. You should not add anything to the standard namespaces or even to your own type namespace.
Typically, the easiest option to create your own node manager is to use TUaNodeManagerUaNode
. With that you can create all the nodes as implementations of TUaNode
objects (or actually subtypes of TUaNode
, such as TUaObject
, TUaVariable
, etc. according to the actual NodeClass of each node).
The following creates a new NodeManager and assigns a sample NamespaceUri for it:
MyNodeManager := TUaNodeManagerUaNode.Create;
MyNodeManager.Namespace :=
TUaNamespace.Get('http://www.prosysopc.com/UA/Delphi/SampleObjects');
UaServer.AddNodeManager(MyNodeManager);
MyNodeManager = new TUaNodeManagerUaNode();
MyNodeManager->Namespace =
TUaNamespace::Get("http://www.prosysopc.com/UA/Delphi/SampleObjects");
UaServer->AddNodeManager(MyNodeManager);
|
4.4.3. Adding Nodes
The simplest way to add nodes in the address space is to use the CreateObject
, CreateVariable
and CreateMethod in `TUaNodeManagerUaNode
.
For example:
// Folder
MyObjectsFolder := MyNodeManager.CreateFolder('MyObjects');
UaServer.AddressSpace.ObjectsFolder.AddOrganizes(MyObjectsFolder);
// Object
MyDevice := MyNodeManager.CreateObject('MyDevice');
MyObjectsFolder.AddOrganizes(MyDevice);
// Variable
MyLevel := MyNodeManager.CreateVariable('MyLevel');
MyLevel.DataTypeId := Id_Double;
MyDevice.AddComponent(MyLevel);
MyLevel.Value := 0.0;
// Folder
TUaFolderType *MyObjectsFolder;
MyObjectsFolder = MyNodeManager->CreateFolder("MyObjects");
ObjectsFolder->AddOrganizes(MyObjectsFolder);
// Object
TUaObject *MyDevice;
MyDevice = MyNodeManager->CreateObject("MyDevice");
MyObjectsFolder->AddOrganizes(MyDevice);
// Variable
MyLevel = MyNodeManager->CreateVariable("MyLevel");
MyLevel->DataTypeId = Id_Double;
MyDevice->AddComponent(MyLevel);
MyLevel->Value = TUaVariant::Create(0.0);
As you can see, we have also a special helper method CreateFolder
that creates an object with TypeDefinition of FolderType.
Also, you can see that for Variables, we can initialize a Value
. The Value is of type TUaVariant
, which resembles the standard Delphi Variant, but is a little different. The idea is similar, however that we can transfer values of different data types between the servers and clients. In Delphi, we can use automatic data conversions with it, whereas in C++ we have to be more specific when creating the values and conversions. The server application is in every case responsible of using values that correspond to the DataTypeId defined for the variable. The SDK does not make any validations.
4.4.4. Array Variables
In order to define array variables, you will first need to declare them as arrays using ValueRank and ArrayDimensions and then use Variant values for the data.
ValueRank
Although this is just informative so that the clients know what to expect, you need to change the ValueRank and ArrayDimensions, if your variable can have array data. For example, to declare a variable length, one-dimensional array:
MyLevel.ValueRank := 1; // dimensions
MyLevel.ArrayDimensions := [0]; // size of each dimension. 0 means flexible.
MyLevel->ValueRank = 1;
MyLevel->ArrayDimensions.Length = 1;
MyLevel->ArrayDimensions[0] = 0;
ValueRank can also be declared using the standard values, if you need more flexibility:
vrScalarOrOneDimension = -3;
vrAny = -2;
vrScalar = -1; // default for variables
vrOneOrMoreDimensions = 0;
Array Values
Array values must be provided with Variant arrays:
var
V: Variant;
...
V := VarArrayCreate([0,1], varDouble);
V[0] := 0.0;
V[1] := 1.0;
MyLevel.Value := TUaVariant.Create(V);
int bounds[2] = {0, 1};
Variant V = VarArrayCreate(bounds, 1, varDouble);
VarArrayRedim(V, 1);
int index = 0;
VarArrayPut(V, 0.0, &index, 0);
index = 1;
VarArrayPut(V, 1.0, &index, 0);
MyLevel->Value = TUaVariant::Create(V);
4.4.5. Variables with Custom Structure Types
Above, we loaded some sample types and now we can use one of them to initialize a variable that has a custom data type.
// Custom Structure Variable
MyStructure := MyNodeManager.CreateVariable('MyStructure');
// DynamicStructureTypeId is defined in SampleNamespace.xml that was loaded above
MyStructure.DataTypeId := DynamicStructureTypeId;
MyDevice.AddComponent(MyStructure);
MyStructure.Value := CreateDynamicStructureValue;
// Custom Structure Variable
MyStructure = MyNodeManager->CreateVariable("MyStructure");
// DynamicStructureTypeId is defined in SampleNamespace.xml that was loaded above
MyStructure->DataTypeId = DynamicStructureTypeId();
MyDevice->AddComponent(MyStructure);
MyStructure->Value = CreateDynamicStructureValue();
The actual value is initialized within the function CreateDynamicStructureValue
and you can find that from the sample server to see how it is defined in detail.
4.4.6. Adding Complete Instance Structures
If you wish to create complete Object and Variable structures that follow the ObjectType or VariableType definitions including all their components and properties, you can use TUaNodeManagerUaNode.CreateInstance
. For example, to create variables of AnalogUnitType, we can use the following function:
function TUaSampleServerForm.CreateAnalogItem(Folder: TUaFolderType; const
Name: string; DataTypeId: TUaNodeId; InitialValue: TUaVariant; EngUnits:
string): TUaAnalogUnitType;
begin
// AnalogItemType defines only EURange mandatory
// AnalogUnitType defines only EngineeringUnits mandatory
// AnalogUnitRangeType defines both EngineeringUnits and EURange mandatory
Result := MyNodeManager.CreateInstance(Id_AnalogUnitType, 'Analog' + Name)
as TUaAnalogUnitType;
Result.DataTypeId := DataTypeId;
Folder.AddComponent(Result);
Result.Value := InitialValue;
Result.EngineeringUnits := TUaEUInformationTable.UNECE.GetByName(EngUnits);
end;
TUaAnalogUnitType* __fastcall TUaCppSampleServerForm::CreateAnalogItem(TUaFolderType* Folder,
const String Name, TUaNodeId& DataTypeId, const TUaVariant& InitialValue,
const String EngUnits)
{
// AnalogItemType defines only EURange mandatory
// AnalogUnitType defines only EngineeringUnits mandatory
// AnalogUnitRangeType defines both EngineeringUnits and EURange mandatory
TUaAnalogUnitType* Result = (TUaAnalogUnitType*) MyNodeManager->CreateInstance(
Id_AnalogUnitType, "Analog" + Name);
Result->DataTypeId = DataTypeId;
Folder->AddComponent(Result);
Result->Value = InitialValue;
Result->EngineeringUnits = TUaEUInformationTable::UNECE->GetByName(EngUnits);
return Result;
}
For Delphi users, there is also a generic version, CreateInstance<T>
which does not require the type cast:
TUaAnalogUnitType AnalogItem := MyNodeManager.CreateInstance<TUaAnalogUnitType>('Analog' + Name);
Note: You can also provide your custom NodeId for the new instance as an optional argument to the method.
Engineering Units
TUaEUInformationTable.UNECE
is a table of standard engineering unit mappings defined in the OPC UA specification Part 8. The table is using the UNECE_to_OPCUA.csv
file, which must be located at the current directory (or in TUaEUInformationTable.CSVDir
).
Note that we are adding OPC UA References to nodes with the methods TUaNode.AddComponent
(ReferenceType=HasComponent) and TUaNode.AddOrganizes
(ReferenceType=Organizes). You could also use TUaNodeManagerUaNode.AddReference
to add a reference between two nodes with any reference type.
4.4.7. Custom Node Manager
Instead of using the TUaNodeManagerUaNode
, you can declare a custom node manager that is derived from TUaNodeManager
. You will need to implement all node handling yourself, but you don’t need to instantiate a TUaNode
object in the memory for every node. This is especially useful if your OPC UA server is just wrapping an existing data store and you do not want to replicate all the data in the memory of the server. Also, if you need to provide access to a large amount of nodes (actual number depending on the amount of memory available), this may be the only option for you.
5. I/O Manager
I/O managers are used to handle read and write calls from the client applications. The abstract base class that defines the interface is TUaIoManager
. The default implementation used in the TUaNodeManagerUaNode
is TUaIoManagerUaNode
. It reads attribute values directly from the TUaNode
objects of the node manager. If you have your own implementation of TUaNodeManager
, you will have to implement the abstract methods of TUaIoManager
yourself.
5.1. Nodes as Data Cache
If you use TUaNode
-based node objects that cache all values in the memory, you do not need to do anything else than provide new values to the objects to make your server work as expected. For example:
MyLevel.Value := 0.0;
5.2. Custom I/O Manager
If you go for the custom node manager, as described in Custom Node Manager, you can also use a custom IoManager
implementation. In case you have your data already in a background system and do not wish to replicate the data in the node objects, you can refer read and write calls to the custom I/O manager that can communicate with the background system.
If you define your own TUaIoManager
implementation, you will have to override the GetIoManagerClass
function in your TUaNodeManager
implementation so that when created, your node manager will use your own I/O manager implementation. For example:
function TUaMyNodeManager.GetIoManagerClass: TUaIoManagerClass;
begin
Result := TUaMyIoManager;
end;
6. Events, Alarms and Conditions
To add support for OPC UA events, you must use an event manager and use event objects to trigger the events.
6.1. Event Manager
The default event manager used by the TUaNodeManagerUaNode
is TUaEventManager
. It handles client commands related to the condition methods. Currently, the only method it can handle is ConditionRefresh.
6.2. Defining Events and Conditions
To actually model events in the address space and trigger them, you can use the event types defined in the SDK.
There are two main types of events: normal events and conditions. Events are just notifications to the client applications, whereas conditions can also contain a state. Therefore, condition nodes are typically also available in the address space as nodes.
6.2.1. Normal Events
To define a normal event, you can use CreateEventInstance
in NodeManagerRoot
:
function TUaSampleServerForm.CreateEvent(Severity: Word): TUaBaseEventType;
begin
Result := (UaServer.NodeManagerRoot as TUaNodeManagerUaNode).CreateEventInstance(Id_SystemEventType);
Result.Message := 'Sample Event';
Result.Severity := Severity;
end;
TUaBaseEventType* __fastcall TUaCppSampleServerForm::CreateEvent(Word Severity)
{
TUaBaseEventType *Event = ((TUaNodeManagerUaNode *)UaServer->NodeManagerRoot)->CreateEventInstance(Id_SystemEventType);
Event->Message = TUaLocalizedText::Create("Sample Event");
Event->Severity = Severity;
return Event;
}
It will create the proper event instance according to the type identifier that you provide.
Then you can just trigger the event as described below in Triggering Events.
6.2.2. Conditions
Conditions are events that have a state. Therefore, they are often found from the address space and the client applications can also check out the current state of the condition from there. Whenever the state changes, the server should send a respective Event Notification to the client. The SDK does not do that automatically, to let you decide when it should happen.
So, first you will have to create a condition and add it to the address space.
For example, ExclusiveLevelAlarmType
, which is a specific condition type is used to initialize an alarm node as follows:
function TUaSampleServerForm.CreateAlarmNode(Source: TUaVariable):
TUaExclusiveLevelAlarmType;
var
MyAlarmId: TUaNodeId;
Name: string;
Namespace: TUaNamespace;
begin
// Level alarm from the LevelMeasurement
Namespace := MyNodeManager.Namespace;
MyAlarmId := TUaNodeId.Create(Namespace, Source.NodeId.ValueAsString + '.Alarm');
Name := Source.BrowseName.Name + 'Alarm';
// Since the HighHighLimit and others are Optional nodes,
// we need to define them to be instantiated.
MyNodeManager.NodeBuilderConfiguration :=
TUaTypeDefinitionBasedNodeBuilderConfiguration.Builder
.AddOptional('HighHighLimit')
.AddOptional('HighLimit')
.AddOptional('LowLimit')
.AddOptional('LowLowLimit')
.Build;
Result := MyNodeManager.CreateInstance(Id_ExclusiveLevelAlarmType, Name, MyAlarmId) as TUaExclusiveLevelAlarmType;
Result.Source := Source;
Result.Message := TUaLocalizedText.Create('Level exceeded');
Result.Severity := 500;
Result.HighHighLimit := 90.0;
Result.HighLimit := 70.0;
Result.LowLimit := 30.0;
Result.LowLowLimit := 10.0;
Source.AddReference(Result, Id_HasCondition, false);
end;
TUaExclusiveLevelAlarmType* __fastcall TUaCppSampleServerForm::CreateAlarmNode(TUaVariable* Source)
{
// Level alarm from the LevelMeasurement
TUaNamespace* Namespace = MyNodeManager->Namespace;
TUaNodeId MyAlarmId = TUaNodeId::Create(Namespace, Source->NodeId.ValueAsString() + ".Alarm");
String Name = Source->BrowseName.Name + "Alarm";
// Since the HighHighLimit and others are Optional nodes,
// we need to define them to be instantiated.
MyNodeManager->NodeBuilderConfiguration =
TUaTypeDefinitionBasedNodeBuilderConfiguration::Builder()
->AddOptional(TUaQualifiedName::Create("HighHighLimit"))
->AddOptional(TUaQualifiedName::Create("HighLimit"))
->AddOptional(TUaQualifiedName::Create("LowLimit"))
->AddOptional(TUaQualifiedName::Create("LowLowLimit"))
->Build();
TUaExclusiveLevelAlarmType* Result = (TUaExclusiveLevelAlarmType*)
MyNodeManager->CreateInstance(Id_ExclusiveLevelAlarmType, Name, MyAlarmId);
Result->Source = Source;
Result->Message = TUaLocalizedText::Create("Level exceeded!");
Result->Severity = 500;
Result->HighHighLimit = 90.0;
Result->HighLimit = 70.0;
Result->LowLimit = 30.0;
Result->LowLowLimit = 10.0;
Source->AddReference(Result, Id_HasCondition, false);
return Result;
}
CreateAlarmNode
also creates a HasCondition
reference from the SourceNode to the Condition. In addition to that, we should create a
Notifier hierarchy using HasEventSource
and HasNotifier
references. These will define how the event areas are defined in the server. In the example, we will define that "MyLevel is an EventSource of MyDevice" and that "MyDevice is a Notifier of MyObjectsFolder".
// Sample alarm node
MyAlarm := CreateAlarmNode(MyLevel);
MyDevice.AddComponent(MyAlarm);
// Event hierarchy
MyDevice.AddReference(MyLevel, Id_HasEventSource, False);
MyObjectsFolder.AddReference(MyDevice, Id_HasNotifier, False);
// Sample alarm node
MyAlarm = CreateAlarmNode(MyLevel);
MyDevice->AddComponent(MyAlarm);
// Event hierarchy
MyDevice->AddReference(MyLevel, Id_HasEventSource);
MyObjectsFolder->AddReference(MyDevice, Id_HasNotifier);
The Server object in the OPC UA Server will always get notified of all events, and you can monitor that in the clients by default, if you don’t want to filter with any areas. In order to show the Notifiers in the Server object, it is good practice to add the respective references, as well.
UaServer.AddressSpace.ServerNode.AddReference(MyObjectsFolder, Id_HasNotifier);
UaServer->AddressSpace->ServerNode->AddReference(MyObjectsFolder, Id_HasNotifier);
6.3. Triggering Events
When you want to send an event from the server and you have created and initialized the state of the event or condition object, you can trigger it.
procedure TUaSampleServerForm.TriggerEvent(Event: TUaBaseEventType);
var
MyEventId: TUaByteString;
begin
MyEventId := TUaEventManager.GetNextUserEventId;
Event.TriggerEvent(MyEventId);
end;
void __fastcall TUaCppSampleServerForm::TriggerEvent(TUaBaseEventType* Event)
{
TUaByteString MyEventId = TUaEventManager::GetNextUserEventId();
Event->TriggerEvent(MyEventId);
}
MyEventId is useful, if you use Alarms that can be acknowledged. The client will provide it back and you can track which event is being acknowledged based on the EventId.
The triggering will take a snapshot of the current state of the event object and sends the respective component and property values to the client applications that are monitoring events in an Event Notification.
7. Methods
To create a Method node, call TUaNodeManager.CreateMethod
. Then, you can also add argument(s) to the method. Finally, add the Method to an Object. For example:
MyMethod := MyNodeManager.CreateMethod('MyMethod');
// NOTE: ValueRank must be defined for each argument - the default
// is OneOrMoreDimension
MyMethod.AddInputArgument(TUaArgument.Builder.
Name('Operation').
DataType(Id_String).
ValueRank(vrScalar). // Scalar is the default
Description(TUaLocalizedText.Create(
'Trigonometric operation. One of ''sin'', ''cos'', ''tan''.')).
Build);
MyMethod.AddInputArgument(TUaArgument.Builder.
Name('Param').
DataType(Id_Double).
ValueRank(vrScalar).
Description(TUaLocalizedText.Create(
'Angle in degrees.')).
Build);
MyMethod.AddOutputArgument(TUaArgument.Builder.
Name('Result').
DataType(Id_Double).
ValueRank(vrScalar).
Description(TUaLocalizedText.Create(
'The result of the operation.')).
Build);
MyDevice.AddComponent(MyMethod);
MyMethod = MyNodeManager->CreateMethod("MyMethod");
TUaLocalizedText Description;
Description = TUaLocalizedText::Create(
"Trigonometric operation. One of ""sin"", ""cos"", ""tan"".");
// NOTE: ValueRank must be defined for each argument - the default
// is OneOrMoreDimension
MyMethod->AddInputArgument(TUaArgument::Builder->
Name("Operation")->
DataType(Id_String)->
Description(Description)->
Build());
Description = TUaLocalizedText::Create("Angle in degrees.");
MyMethod->AddInputArgument(TUaArgument::Builder->
Name("Param")->
DataType(Id_Double)->
Description(Description)->
Build());
Description = TUaLocalizedText::Create("The result of the operation.");
MyMethod->AddOutputArgument(TUaArgument::Builder->
Name("Result")->
DataType(Id_Double)->
Description(Description)->
Build());
MyDevice->AddComponent(MyMethod);
7.1. Handling Methods
A method manager handles incoming Method calls from clients. It dispatches the calls to call handlers and returns the result to the client. You will need to handle the related Method calls by implementing a method manager or a call handler for the default method manager, TUaMethodManagerUaNode
. If you create a custom manager, the implementation should be based on the TUaMethodManager
class. If you wish to use the default manager, define a call handler, which is of the following form:
TUaCallEvent = function(Sender: TObject; const ObjectId, MethodId: TUaNodeId;
const InputArguments: TArray<Variant>; const InputArgumentResults:
TArray<TUaStatusCode>; const InputArgumentDiagnosticInfos:
TArray<TUaDiagnosticInfo>; var OutputArguments: TArray<Variant>): Boolean
of object;
Then, give the implementation to the method manager:
(MyNodeManager.MethodManager as TUaMethodManagerUaNode).AddCallMethod(CallHandler);
dynamic_cast<TUaMethodManagerUaNode*>(MyNodeManager->MethodManager)-> AddCallMethod(CallHandler);
See the sample project for the details of the CallHandler.
8. History Manager
A history manager enables you to handle all historical data and event functionality. There is no default functionality for these in the SDK, so you must keep track of the historical data yourself and implement the services.
Again, you have two different options for implementing history management:
-
You can define your own subclass of the
TUaHistoryManager
class and override the methods that deal with the various history operations. And then just replaceMyNodeManager.HistoryManager
with your history manager. -
You can simply add event handlers to the default history manager in the node manager.
We will follow the latter here.
The following is a sample historian implementation, which relies on memory-based ValueHistory
objects that handle the history of each node that is added to the historian:
//: A sample implementation of a data historian.
TMyHistorian = class(TComponent)
private
FVariableHistories: TDictionary<TUaNode, TValueHistory>;
public
constructor Create(AOwner: TComponent); override;
destructor Destroy; override;
//: Add the variable to the historian.
procedure AddVariableHistory(Variable: TUaVariable);
procedure ReadRaw(Sender: TObject; ServiceContext: IUaServiceContext;
TimestampsToReturn: TUaTimestampsToReturn; NodeId: TUaNodeId; Node:
TUaNode; UserContinuationPoint: Pointer; StartTime, EndTime: TDateTime;
NumValuesPerNode: Cardinal; ReturnBounds: Boolean; IndexRange: string; var
HistoryData: IUaHistoryData; var ContinuationPoint: Pointer);
end;
class TMyHistorian : public TComponent
{
private:
std::map<TUaNode*, TValueHistory> FvariableHistories;
public:
__fastcall TMyHistorian(TComponent* AOwner);
void __fastcall AddVariableHistory(TUaVariable *Variable);
void __fastcall ReadRaw(TObject *Sender, _di_IUaServiceContext ServiceContext,
TUaTimestampsToReturn TimestampsToReturn,const TUaNodeId &NodeId, TUaNode *Node,
Pointer UserContinuationPoint, TDateTime StartTime, TDateTime EndTime,
Cardinal NumValuesPerNode, bool ReturnBounds, String IndexRange,
_di_IUaHistoryData &HistoryData, Pointer &ContinuationPoint);
};
You can look up the full implementation, including the implementations for ValueHistory
,
from the sample code to find out the actual algorithms.
And then we just plug the OnReadRaw event handler and add the variables that we wish to record in it:
procedure TUaSampleServerForm.InitHistory;
begin
if not Assigned(MyHistorian) then
begin
MyHistorian := TMyHistorian.Create(Self);
MyNodeManager.HistoryManager.OnReadRaw := MyHistorian.ReadRaw;
MyHistorian.AddVariableHistory(MyLevel);
end;
end;
void __fastcall TUaCppSampleServerForm::InitHistory()
{
if (MyHistorian == NULL) {
MyHistorian = new TMyHistorian(this);
MyNodeManager->HistoryManager->OnReadRaw = MyHistorian->ReadRaw;
MyHistorian->AddVariableHistory(MyLevel);
}
}
9. Start Up
Once you have initialized your server, you simply need to start it:
UaServer.Start;
UaServer->Start();
10. Shutdown
Once you are ready to close the server, call UaServer.Shutdown
to notify the clients before the server actually closes down.
UaServer.Shutdown(5, TUaLocalizedText.Create('Shutdown by user'));
The first argument for the method defines the delay (in seconds) until the server shuts down after notifying the clients. The second argument defines the reason for the shutdown. These will be used to set the respective values in ServerStatus, which gives the client applications the possibility to notice a clean shutdown, instead of just losing the connection. In practice, it does not typically change the client behavior very much, though. |
11. Information Modeling
OPC UA Types are typically modeled beforehand. They can be provided by the OPC Foundation that has published a lot of Companion Specifications, which define standard types for various industrial domains. You can also create your own types to define common structures that you wish to use in your server.
The SDK supports loading information models in the NodeSet2 XML format, which is commonly used to provide the modesl to applications. A recommended tool for creating the models is the OPC UA Modeler.
11.1. Loading Information Models
You can load an information model (for example “SampleTypes.xml”) to the server with
UaServer.AddressSpace.LoadModel('SampleTypes.xml');
UaServer->AddressSpace->LoadModel("SampleNamespace->xml");
This will add all types and instances defined in the XML file to the address space.
The SDK ships with the standard OPC UA information model, which is always initialized into the NodeManagerRoot
.