.NET Based OPC UA Client/Server SDK  3.0.9.490
.NET SDK Demo Server

The OPC UA Demo Server delivered with the .NET SDK is a collection of examples for developing different features of an OPC UA server and contains the Unified Automation Demo address space with nodes in the Demo folder.

The source code for the Unified Automation .NET based OPC UA demo server is included in the SDK. The respective Visual Studio Solution can be found in Examples → UA Demo Server.

FileModel Example

The FileModel class implements support for the FileType object on the server side, which can be used to give an OPC UA client access to a file managed by the OPC UA server.

The .NET OPC UA Demo Server contains an example in the Address Space in the folder Objects → Demo → 014_Files. The example code can be found in the Visual Studio Solution for the Demo Server in the file Demo → DemoNodeManager.File.cs.

The following paragraphs describe the method SetupFileObject which creates a new file object. It takes the following arguments:

parentId
The NodeId of the parent node. The created File object will be referenced by an Organizes reference.
browseName
The browse name of the new File object. The browseName is also used to create the NodeId of the File object.
filePath
The file path on the disk.
description
Used for the description attribute of the new file object.

Create the OPC UA Nodes

NodeId objectId = new NodeId("Demo.Files." + browseName, DefaultNamespaceIndex);
CreateObjectSettings settings = new CreateObjectSettings()
{
ParentNodeId = parentId,
ReferenceTypeId = ReferenceTypeIds.Organizes,
RequestedNodeId = objectId,
BrowseName = new QualifiedName(browseName, DefaultNamespaceIndex),
TypeDefinitionId = ObjectTypeIds.FileType
};
if (description != null)
{
settings.Description = new LocalizedText(description);
}
CreateObject(Server.DefaultRequestContext, settings);

Create the Model Class

UnifiedAutomation.UaServer.FileModel

FileModel file = new FileModel();

Set the FileModel Properties

file.Writable = true;
file.UserWritable = true;
file.MaxFileSize = 10000;
file.FileOnDisk = new System.IO.FileInfo(filePath);

Create a Default File if None Exists

if (!file.FileOnDisk.Exists)
{
using (var ostrm = file.FileOnDisk.Open(System.IO.FileMode.Create, System.IO.FileAccess.Write, System.IO.FileShare.None))
{
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(
"This file is used for demonstrating the OPC UA FileType.\n"
+ "You can call the Open method of the TextFile object with Mode argument 1 for ReadOnly.\n"
+ "You have to remember the returned FileHandle argument.\n"
+ "This FileHandle can be used for reading the file by calling the Read method and closing\n"
+ "the file by calling the Close method.");
ostrm.Write(bytes, 0, bytes.Length);
}
file.FileOnDisk.Refresh();
}

Set the File Size Depending on the File Content

file.Size = (ulong)file.FileOnDisk.Length;

LinkModelToNode

UnifiedAutomation.UaServer.BaseNodeManager.LinkModelToNode

LinkModelToNode(objectId, file, null, null, 500);

Write Implementation for DiscreteItemTypes

The DemoServer Address Space contains examples of the following Subtypes of the DiscreteItemType in Demo → 010_ComplianceTest → DA Profile → DataItemDiscreteType:

  1. TwoStateDiscreteType
  2. MultiStateDiscreteType
  3. MultiStateValueDiscreteType

TwoStateDiscreteType

The DataType of an instance of TwoStateDiscreteType is Boolean. The text representing the value is stored in the properties TrueState and FalseState. So it is not necessary to prevent clients from writing values that are not allowed.

MultiStateDiscreteType

Instances of a MultiStateDiscreteType have a property called EnumStrings. The value of this property is an array of LocalizedTexts. The corresponding text for a value can be found at the array index of the value of the property. E.g. the corresponding textual representation of the value 2 can be found at the array index 2 of the value of the property.

So the allowed values for an instance of MultiStateDiscreteType are:
0 ≤ allowed value < array length.

If a client tries to write a value that is not allowed, the server has to return BadOutOfRange (see example code below). The code snippet is taken from the class EnumStringDataSource which can be found in the file Demo → ValueDataStore.cs in the Visual Studio Solution for the Demo Server.

public override StatusCode Write(int componentIndex, Variant value, StatusCode status, DateTime timestamp)
{
if (timestamp == DateTime.MinValue)
{
timestamp = DateTime.UtcNow;
}
if (componentIndex == 0)
{
UnifiedAutomation.UaBase.TypeInfo typeInfo = TypeUtils.IsInstanceOfDataType(value, TypeInfo);
if (typeInfo == null)
{
return StatusCodes.BadTypeMismatch;
}
try
{
int newValue = value.ToInt32();
if (newValue >= 0 && newValue < EnumStrings.Length)
{
SelectedIndex = newValue;
Status = status;
Timestamp = timestamp;
return StatusCodes.Good;
}
}
catch (Exception)
{
// assume out of range error.
}
return StatusCodes.BadOutOfRange;
}

MultiStateValueDiscreteType

The behavior for an instance of MultiStateValueDiscretetType is quite similar. The instances have a property called EnumValues. The value of this property contains an array of EnumValueType. I.e. the value contains an array of value-text pairs. So the server has to check wheter the value of the property contains the value to write (see example below). The code snippet is taken from the class EnumValueDataSource which can be found in the file Demo → ValueDataStore.cs in the Visual Studio Solution for the Demo Server.

public override StatusCode Write(int componentIndex, Variant value, StatusCode status, DateTime timestamp)
{
if (timestamp == DateTime.MinValue)
{
timestamp = DateTime.UtcNow;
}
if (componentIndex == 0)
{
UnifiedAutomation.UaBase.TypeInfo typeInfo = TypeUtils.IsInstanceOfDataType(value, TypeInfo);
if (typeInfo == null)
{
return StatusCodes.BadTypeMismatch;
}
try
{
long newValue = value.ToInt64();
for (int ii = 0; ii < EnumValues.Length; ii++)
{
if (EnumValues[ii].Value == newValue)
{
SelectedIndex = ii;
Status = status;
Timestamp = timestamp;
return StatusCodes.Good;
}
}
}
catch (Exception)
{
// assume out of range error.
}
return StatusCodes.BadOutOfRange;
}

LocalizedData Example

The Demo Server contains an example for a variable containing localized data in the folder Objects → Demo → 011_UnicodeTest. The variable IDS_HELLO_WORLD has localizations for display name, description, and the value in English, German, and French.

The variable IDS_HELLO_WORLD is created in the method SetupLocalizedVariable which can be found in the file Demo → DemoNodeManager.cs in the Visual Studio Solution for the Demo Server.

The individual translations have to be added to the ResourceManager as shown in the following code snippet.

UnifiedAutomation.UaServer.ResourceManager

Server.ResourceManager.Add("IDS_HELLO_WORLD", "en-US", "Hello World");
Server.ResourceManager.Add("IDS_HELLO_WORLD", "de-DE", "Hallo Welt");
Server.ResourceManager.Add("IDS_HELLO_WORLD", "fr-CA", "Bonjour tout le monde");
Server.ResourceManager.Add("IDS_LIGHT_RED", "en-US", "Red");
Server.ResourceManager.Add("IDS_LIGHT_RED", "de-DE", "Rot");
Server.ResourceManager.Add("IDS_LIGHT_RED", "fr-CA", "Rouge");
Server.ResourceManager.Add("IDS_LIGHT_GREEN", "en-US", "Green");
Server.ResourceManager.Add("IDS_LIGHT_GREEN", "de-DE", "Grün");
Server.ResourceManager.Add("IDS_LIGHT_GREEN", "fr-CA", "Verte");

When in-memory nodes are read, the Translate method of the ResourceManager is called by the SDK automatically.

To test the translations, set the configuration parameter General.LocaleId of UaExpert to de-DE, connect to the .NET Demo Server and read the Variable. The german localizations of DisplayName, Description and Value will show up (see screenshot).

demoserver_localizeddata.png

ModelChangeEvent Example

ModelChange events can be fired by the server if nodes or references are added or deleted during runtime. A client that subscribes to ModelChanegEvents is able to detect these changes to the information model. E.g. UaExpert can refresh the information model tree view automatically so that the user doesn’t need to call rebrowse manually.

The folder 008_DynamicNodes in the address space contains a property called NodeVersion. The OPC UA Specification defines that such a property is required if ModelChangeEvents are being fired for a node. In the following example, a node will be added to the folder 008_DynamicNodes, resulting in a ModelChangeEvent being fired. Thus, it is necessary to add a NodeVersion property to the folder. In our example, the property is already part of the address space imported from the XML file.

The OPC UA specification defines the method CreateDynamicNode. The .NET implementation is the method DoCreateDynamicNode which can be found in the file DemoNodeManager.Methods.cs. The following code snippet shows how to add the node.

string name = "DynamicNode";
CreateVariableSettings settings = new CreateVariableSettings()
{
ParentNodeId = new NodeId("Demo.DynamicNodes", DefaultNamespaceIndex),
ReferenceTypeId = ReferenceTypeIds.HasComponent,
RequestedNodeId = new NodeId("Demo.DynamicNodes." + name, DefaultNamespaceIndex),
BrowseName = new QualifiedName(name, DefaultNamespaceIndex),
DisplayName = name,
AccessLevel = AccessLevels.CurrentReadOrWrite,
DataType = DataTypeIds.UInt32,
ValueRank = ValueRanks.Scalar,
Historizing = false,
TypeDefinitionId = VariableTypeIds.BaseDataVariableType
};
VariableNode parentNode = CreateVariable(Server.DefaultRequestContext, settings);

In this example, the ModelChangeEvent is not fired directly from the method which creates the dynamic node. Instead the method AfterAddReference (which can be found in the file DemoNodeManager.cs) is used, which is called by the SDK after a reference has been added. The UpdateNodeVersion method tries to update the NodeVersion property and to fire the event.

First, the method checks the UserData wheter node versioning is supported.

NodeWithVersioning data = node.UserData as NodeWithVersioning;
if (data == null)
{
return false;
}

If so, the node version is updated.

lock (m_datasources)
{
DataVariableDataSource datasource = data.Source;
int version = datasource.Value.ToInt32();
version++;
datasource.Value = version.ToString();
ReportChange(context, datasource, 0);
}

Finally, the event is fired.

GenericEvent e = new GenericEvent(Server.FilterManager);
e.Initialize(
null,
ObjectTypeIds.GeneralModelChangeEventType,
ObjectIds.Server,
BrowseNames.Server,
"The address space has changed.");
ModelChangeStructureDataType[] changes = new ModelChangeStructureDataType[1];
changes[0] = new ModelChangeStructureDataType();
changes[0].Affected = node.NodeId;
changes[0].AffectedType = (instance != null) ? instance.TypeDefinitionId : null;
changes[0].Verb = (byte)verb;
e.Set(BrowseNames.Changes, new Variant(changes));
// report the event.
Server.ReportEvent(e);

User Authentication

The Demo Server contains an example that shows how to allow users to authenticate with different UserIdentityTokens. The following section gives an overview of the most important parts of the implementation. The complete source code can be found in the Visual Studio solution for the Demo Server (file TestServerManager.cs).

Adding an ImpersonateEventHandler to the SessionManager

This event handler is called in ActivateSession and passes the IdentityToken of the client.

this.SessionManager.ImpersonateUser += new ImpersonateEventHandler(SessionManager_ImpersonateUser);

and

private void SessionManager_ImpersonateUser(Session session, ImpersonateEventArgs args)

Check for AnonymousIdentityToken

AnonymousIdentityToken anonynmousToken = args.NewIdentity as AnonymousIdentityToken;
if (anonynmousToken != null)
{
return;
}

Check for UserNameIdentityToken

The Demo Server accepts four hard-coded UserNameIdentityTokens as listed in the following table.

UserName Password
john master
joe god
sue curly
root secret
Note
For simplicity, the usernames and passwords in this example are specified in code. In real world applications, you should use some kind of database.

Users with Restricted Access

The users john, joe, and sue have been given restricted access. The code snippet shows the example code for the user john.

UserNameIdentityToken userNameToken = args.NewIdentity as UserNameIdentityToken;
if (userNameToken != null)
{
if (String.IsNullOrEmpty(userNameToken.UserName))
{
args.IdentityValidationError = StatusCodes.BadIdentityTokenInvalid;
return;
}
if (userNameToken.UserName == "john")
{
if (userNameToken.DecryptedPassword != "master")
{
args.IdentityValidationError = StatusCodes.BadUserAccessDenied;
}
return;
}

User root with Administrator Rights

To give the user root administrative rights, the EffectiveIdentity is set.

if (userNameToken.UserName == "root")
{
if (userNameToken.DecryptedPassword != "secret")
{
args.IdentityValidationError = StatusCodes.BadUserAccessDenied;
}
else
{
args.EffectiveIdentity = new UserIdentity(userNameToken, true);
}
return;
}

Logon with Username and Password of the Local Machine

The following code snippets show how to use username and password of the local windows installation for server logon.

LogonUser(userNameToken);
private static class Win32
{
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool LogonUser(
string lpszUsername,
string lpszDomain,
string lpszPassword,
int dwLogonType,
int dwLogonProvider,
ref IntPtr phToken);
[DllImport("kernel32.dll", CharSet = CharSet.Auto)]
public extern static bool CloseHandle(IntPtr handle);
}
private void LogonUser(UserNameIdentityToken identity)
{
IntPtr handle = IntPtr.Zero;
const int LOGON32_PROVIDER_DEFAULT = 0;
const int LOGON32_LOGON_NETWORK = 3;
bool result = Win32.LogonUser(
identity.UserName,
String.Empty,
identity.DecryptedPassword,
LOGON32_LOGON_NETWORK,
LOGON32_PROVIDER_DEFAULT,
ref handle);
if (!result)
{
throw new StatusException(StatusCodes.BadUserAccessDenied, "Login failed for user: {0}", identity.UserName);
}
}

Check for IssuedIdentityToken and KerberosIdentityToken

It is also possible to use an IssuedIdentityToken or an KerberosIdentityToken for user logon. The respective checks are not shown here, but can be found in sample code.

Assigning Access Rights to Nodes

The Demo Server contains example code for assigning access rights to nodes, which can be found in the file Demo → DemoNodeManager.AccessControl.cs and AccessControlManager.cs in the Visual Studio Solution for the Demo Server. It shows how to control the read and write access for Variable values and the browse access for specific nodes.

There are several virtual HasAccess methods defined at the BaseNodeManager. These methods control whether some specific content is send to a client. The default implementation always returns true.

In the example, all knowledge needed for access control is hard-coded. In real world applications, the information should be received by some kind of database.

The folder containing nodes with access rights that are different for specific users can be found in the folder Objects → Demo → 005_AccessRights in the Demo Server address space. There are several subfolders:

Access_All
contains Variables having value attributes that can be read and written by everybody.
Access_John
contains Variables having value attributes that can be read and written by john only (John_RO, John_RW, John_WO) and Variables where john has full access and other users have restricted access
Access_Operators
contains Variables having value attributes that can be read and written by users of the operators group (Operators_RO, Operators_RW, Operators_WO) and Variables where john has full access and other users have restricted access.
Browse_All
can be browsed by all users.
Browse_John
can only be browsed by the user john.
Browse_Operators
can be browsed by users of the operators group.

A list of users and their passwords can be found in the description of the User Authentication example.

In this example, the class AccessControlManager

  • manages the users (AccessControlManager.AddUser),
  • has hard coded user groups (enum AccessControlGroup),
  • and checks whether a node’s content can be accessed.

To use this class, we add the AccessControlSettings to the UserData of a node (see DemoNodeManager.SetupAccessControl()). When the SDK calls the methods

  • HasAccess(RequestContext context, BrowseHandle browseHandle)
  • or HasAccess(RequestContext context, NodeAttributeHandle nodeHandle, UserAccessMask accessMasks),

the UserData is checked. If the UserData has the correct type, the HasAccess method of the AccessControlManager is called.

Exposing Structured Data Type Members in the Address Space

Overview

There are use cases where one might want to create VariableTypes that expose fields of a structure to the children of the VariableType:

  • Clients expected to connect to the server cannot deal with structured DataTypes.
  • Clients are only interested in accessing specific fields of the structure.

There are several VariableTypes in namespace 0 having children which expose fields of the structure. An example is ServerStatusType. The DataType of its Value attribute is a structured DataType, the ServerStatusDataType. This structure has the fields StartTime, CurrentTime, State, BuildInfo SecondsTillShutdown, and ShutdownReason. For each of these fields, the ServerStatusType has a corresponding child. The instance of this type in the Demo Server address space is the ServerStatus variable of the Server object.

This example describes a simplified case which is also shown in the Demo Server: The structured DataType WorkOrderType and the corresponding WorkOrderVariableType. The instances of the latter are WorkOrderVariable and WorkOrderVariable2 in the folder Objects → Demo → 015_WorkOrder in the Demo Server Address Space.

When using such a scenario, the developer has to ensure that when the value of the instance is changed, the value of the child is changed as well (and vice versa).

Implementation

The implementation can be found in the files Demo → DemoNodeManager.cs and Controllers → WorkOrderVariableModelController.cs

The Type nodes and the instance nodes are created when importing the Demo Server address space from the file demoserver.xml, which is not explained in this section.

We use the method LinkModelToNode to keep the values of the instances of WorkOrderVariableType and its children consistent (see DemoNodeManager.cs).

private void SetupWorkOrderModel()
{
NodeId WorkOrderId = new NodeId(Model.Variables.Demo_WorkOrder_WorkOrderVariable, DefaultNamespaceIndex);
Model.WorkOrderType value = new Model.WorkOrderType();
SetVariableDefaultValue(WorkOrderId, new Variant(value));
Model.WorkOrderVariableModel model = new Model.WorkOrderVariableModel()
{
Value = value
};
LinkModelToNode(WorkOrderId, model, null, null, 0);
WorkOrderId = new NodeId(Model.Variables.Demo_WorkOrder_WorkOrderVariable2, DefaultNamespaceIndex);
value = new Model.WorkOrderType();
SetVariableDefaultValue(WorkOrderId, new Variant(value));
model = new Model.WorkOrderVariableModel()
{
Value = value
};
LinkModelToNode(WorkOrderId, model, null, null, 0);
}

The remaining parts of the implementation can be found in the file WorkOrderVariableModelController.cs.

We have to extend the auto-generated class WorkOrderVariableModel by overriding GetModelHandle to assign custom getters and setters.

Check arguments:

private WorkOrderVariableModel m_model;
m_model = instance as WorkOrderVariableModel;
if (m_model == null)
{
return null;
}

Get a default model handle

ModelHandle handle = ModelMapper.GetModelHandle(namespaceUris, m_model);

Assign custom handlers to each field

ushort nsIdx = (ushort)namespaceUris.IndexOf(Model.Namespaces.Model);
foreach (ModelMapping mapping in handle.Mappings)
{
if (mapping.BrowsePath.Length == 1)
{
if (mapping.BrowsePath[0] == new QualifiedName(BrowseNames.AssetID, nsIdx))
{
mapping.MappingData = new Delegate[] {
// the ModelMappingGetValueHandler shall be the first handler in the array
new ModelMappingGetValueHandler(GetAssetID),
// the ModelMappingSetValueHandler shall be the second handler in the array
};
}
private DataValue GetAssetID(IMapperContext context)
{
return new DataValue()
{
WrappedValue = new Variant(m_model.Value.AssetID),
SourceTimestamp = DateTime.UtcNow,
ServerTimestamp = DateTime.UtcNow
};
}
private void SetAssetID(IMapperContext context, DataValue AssetId)
{
m_model.Value.AssetID = AssetId.WrappedValue.ToString();
}

LargeArrayNodeManager

The LargeArrayNodeManager is an example for implementing a NodeManager without using the toolkit layer of the SDK. The nodes managed by this NodeManager can be found in the TenMillion node (Demo → 006_Massfolder_Static → TenMillion).

The class LargeArrayNodeManager demonstrates implementations for the interfaces INodeManager and IIOManager.

You can find custom implementations for

  • Browse
  • TranslateBrowsePath
  • Read
  • Write
  • DataMonitoring

Implementing these methods also requires to implement INodeManager.GetBrowseHandle and INodeManager.GetNodeHandle. The other required Get<MethodName>Handle methods are implemented, but contain only a dummy implementation.

Program Example

The Program example is a basic implementation of an OPC UA Program that counts a value down from 1000 to 0. Please read the short introduction to Programs if you’re not familiar with the concept.

The Demo Server address space contains the type CountdownStateMachineType having the child Value. This custom ProgramType is a subtype of the ProgramStateMachineType. An instance of this type can be found in the folder Demo → 016_StateMachines → Program. The sample code can be found in the file DemoNodeManager.StateMachines.cs.

Enumeration for States

For internal processing of the ProgramStateMachine we create an enumeration containing the possible states.

internal enum ProgramState
{
Ready = 1,
Running = 2,
Suspended = 3,
Halted = 4
}

The generated class CountdownStateMachineModel is extended with this enumeration.

public partial class CountdownStateMachineModel
{
internal ProgramState State
{
get
{
return m_state;
}
set
{
m_state = value;
ProgramState m_state;

Implement Methods

There are several transitions defined for the Program Finite State Machine. We define methods for each transition. In these methods the current state and the last transition is set.

The business logic is implemented here as well: e.g. in ReadyToRunning a timer is started that counts the the variable Value down. In addition, the TransitionEvents are fired.

To access all data that is needed to fire the event fields, we extend the generated class CountdownStateMachineModel.

  • We need a reference to DemoNodeManager to call methods implemented by the NodeManager.
  • ProgramTransitionEventModel is needed to store and set the event data.
  • ProgramId and ProgramName are needed to fire events.
private Timer Timer { get; set; }
internal DemoNodeManager NodeManager { get; set; }
internal ProgramTransitionEventModel Event { get; set; }
internal NodeId ProgramId { get; set; }
internal string ProgramName { get; set; }
void ReadyToRunning(Model.CountdownStateMachineModel model)
{
model.LastTransition.Value = new LocalizedText("ReadyToRunning");
model.LastTransition.Id = ObjectIds.ProgramStateMachineType_ReadyToRunning;
model.LastTransition.Number = (uint) ProgramTransitions.ReadyToRunning;
model.LastTransition.TransitionTime = DateTime.UtcNow;
model.State = Model.ProgramState.Running;
model.StartTimer();
FireEvent(model);
void FireEvent(Model.CountdownStateMachineModel model)
{
model.Event.Transition.Id = model.LastTransition.Id;
model.Event.Transition.Value = model.LastTransition.Value;
model.Event.IntermediateResult = model.Value;
model.Event.Time = DateTime.UtcNow;
GenericEvent e = model.Event.CreateEvent(Server.FilterManager, true);
ReportEvent(new NodeId(UnifiedAutomation.Demo.Model.Objects.Demo_StateMachines_Program, DefaultNamespaceIndex), e);
}
internal void StartTimer()
{
Timer = new Timer(OnTimer, null, 100, 100);
}
private void OnTimer(object state)
{
Value--;

The transition methods will be called by the OPC UA Method implementation. When an OPC UA Method is called, we need to check if it is possible to call the Method, since not all Methods can be called for all states of the program. If the Method can be called by the client, the transition method is called in the OPC UA Method implementation.

The methods of the ProgramState are implemented by the DemoNodeManager. So DemoNodeManager needs to implement the Interface IProgramStateMachineMethods.

internal partial class DemoNodeManager : Model.ICountdownStateMachineMethods
public StatusCode Start(RequestContext context, Model.CountdownStateMachineModel model)
{
var countDown = model as Model.CountdownStateMachineModel;
if (!CanStart(countDown))
{
return StatusCodes.BadInvalidState;
}
countDown.Event.SourceName = "Start";
countDown.Event.SourceNode = countDown.StartId;
ReadyToRunning(countDown);
return StatusCodes.Good;
}

LinkModelToNode

After implementing the methods we need to link the CountdownStateMachineModel to the nodes in the address space.

NodeId programId = new NodeId(Demo.Model.Objects.Demo_StateMachines_Program, DefaultNamespaceIndex);
var program = new Demo.Model.CountdownStateMachineModel()
{
Value = 1000,
CountdownStateMachineMethods = this,
NodeManager = this,
Event = new ProgramTransitionEventModel(),
ProgramId = programId,
ProgramName = "Program"
};
program.State = Demo.Model.ProgramState.Ready;
program.Event.Severity = (ushort) EventSeverity.Medium;
program.Event.EventType = ObjectTypeIds.TransitionEventType;
program.Event.Message = new LocalizedText("Transition");
program.GetMethodIds();
LinkModelToNode(
programId,
program,
null,
null,
0);

Set Executable Attribute

Depending on the current state of the Program Finite State Machine, some Methods cannot be called by the client. So we set the Executable attribute to false if a Method cannot be called by a client. We add the NodeIds of the Methods as property to the class CountdownStateMachineModel and use the ServerInternalClient to get them.

The transition methods are setting the Executable attributes.

public NodeId HaltId { get; private set; }
public NodeId ResetId { get; private set; }
public NodeId ResumeId { get; private set; }
public NodeId StartId { get; private set; }
public NodeId SuspendId { get; private set; }
public void GetMethodIds()
{
var result = new BrowsePathResult();
NodeManager.Server.InternalClient.Translate(
NodeManager.Server.DefaultRequestContext,
ProgramId,
0,
result);
StartId = (NodeId) result.Targets[0].TargetId;
SetExecutable(program.ResetId, false);
SetExecutable(program.ResumeId, false);
SetExecutable(program.SuspendId, false);
void SetExecutable(NodeId nodeId, bool value)
{
Server.InternalClient.WriteAttribute(
Server.DefaultRequestContext,
nodeId,
Attributes.Executable,
new Variant(value));
}

Test with UaExpert

Finally, we can test the implementation with a client, e.g. UaExpert.

First we drag and drop the variables Value, CurrentState, and LastTransition to the DA View (see screenshot below). The variable CurrentState has the value Ready, because the program hasn’t been started yet. Thus, LastTransition is empty, because no transition has taken place. The value of Value is 1000, because the countdown hasn’t started yet.

demoserver_program_1.png
DA View before starting the program

Then choose DocumentAdd… from the menu. Select Event View from the drop-down list and confirm with OK. Drag and drop the Program object to the event view (see screenshot). We are now ready to receive transition events.

demoserver_program_2.png
Event View containing the program object

Switch back to the DA View and call the method Start. The value of CurrentState has changed to running. LastTransition has the value ReadyToRunning and the value of Value is decreasing. When switching to the Event View, we can see the transition event in the list of events.

demoserver_program_3.png
DA View containing the changed variable values and Event View with transition event

Switch back to the DA View and call the method Suspend. The countdown stops and the values of CurrentState and LastTransition change accordingly. When calling the method Suspend again, you will get an error, because the method cannot be called in the suspended state.

Call the method Resume and wait for the program to finish if you like.

Slow Data Source Example

The Demo Server address space contains Variables that return their Read and Write results very slowly. They can be found in in the folder Demo → 017_SpecialVariables. The source code can be found in the file DemoNodeManager.SlowDataSource.cs

Background

In real-world applications, nodes in UA servers may represent devices that are answering very slowly, e.g. in error case. If access to these data sources is implemented incorrectly, the server blocks as long as the call to the API of the data source is outstanding. The implementation in the server is different if the API for accessing the device is synchronous or asynchronous.

Data Sources

In our example, there are classes that simulate slowly responding devices. The devices have methods for reading and writing. For simplicity, the device is represented by a single UINT32 value. There is also a simulation in the device classes: The value is incremented every 500 ms (see sample code below).

#region Base class
internal class SlowDataSource
{
public SlowDataSource()
{
m_timer = new Timer(DoUpdate, null, 500, 500);
}
void DoUpdate(object state)
{
lock (m_lock)
{
m_value++;
}
}
protected int m_delay = 2000;
protected uint m_value = 0;
private Timer m_timer;
protected object m_lock = new object();
}
#endregion
#region Synchronous API
internal class SlowDataSourceSynchronousAPI : SlowDataSource
{
public uint ReadValue()
{
System.Threading.Thread.Sleep(m_delay);
uint ret;
lock (m_lock)
{
ret = m_value;
}
return ret;
}
public bool WriteValue(uint value)
{
System.Threading.Thread.Sleep(m_delay);
lock (m_lock)
{
m_value = value;
}
return true;
}
}
#endregion
#region Asynchronous API
internal class SlowDataSourceAsynchronousAPI : SlowDataSource
{
public delegate void ReadComplete(uint value, object userData);
public delegate void WriteComplete(bool status, object userData);
public void BeginRead(ReadComplete callback, object userData)
{
ThreadPool.QueueUserWorkItem(Read, new ReadData()
{
Callback = callback,
UserData = userData
});
}
class ReadData
{
public ReadComplete Callback { get; set; }
public object UserData { get; set; }
}
private void Read(object state)
{
System.Threading.Thread.Sleep(m_delay);
ReadData data = state as ReadData;
ReadComplete callback = state as ReadComplete;
lock (m_lock)
{
data.Callback(m_value, data.UserData);
}
}
public void BeginWrite(uint value, WriteComplete callback, object userData)
{
ThreadPool.QueueUserWorkItem(Write, new WriteData()
{
Callback = callback,
Value = value,
UserData = userData
});
}
private void Write(object state)
{
System.Threading.Thread.Sleep(m_delay);
WriteData data = state as WriteData;
WriteComplete callback = state as WriteComplete;
lock (m_lock)
{
m_value = data.Value;
}
data.Callback(true, data.UserData);
}
private class WriteData
{
public WriteComplete Callback { get; set; }
public uint Value { get; set; }
public object UserData { get; set; }
}
}
#endregion

Wrapper Classes

We create wrapper classes for the devices and use these wrappers to configure the variables (see sample code below). The main usage of these wrappers will be explained later when the code for monitoring is added.

internal class SlowDataSourceWrapperSynchronous : SlowDataSourceWrapper
{
public SlowDataSourceWrapperSynchronous(SlowDataSourceSynchronousAPI dataSource)
{
m_dataSource = dataSource;
}
public SlowDataSourceSynchronousAPI DataSource
{
get
{
return m_dataSource;
}
}

Read

Reading the device values takes some time. So we must not read these values in the method

RequestContext context,
NodeAttributeHandle nodeHandle,
string indexRange,
QualifiedName dataEncoding)

since the server would be blocked until this call has returned. So we need to call the Read method of the device in the method

protected override void Read(
RequestContext context,
TransactionHandle transaction,
IList<NodeAttributeOperationHandle> operationHandles,
IList<ReadValueId> settings)

and invoke the callback of the transaction when the read of the device has finished.

Synchronous

For the synchronous API, we can use the ApplicationThreadPool to queue the Read calls. This example is designed to be adapted to other data sources. You only need to implement the ReadInJob delegate–the method OnReadSlowVariable in this case–and copy and paste the remaining code to your application.

if (operationHandles[ii].NodeHandle.UserData is SlowDataSourceWrapperSynchronous)
{
// We have to read the value in a thread
DoReadInJob(
new ReadInJobData()
{
Context = context,
Transaction = transaction,
OperationHandle = operationHandles[ii],
ReadValueId = settings[ii],
Callback = OnReadSlowVariable
});
}
private void DoReadInJob(
ReadInJobData data
)
{
Server.ThreadPool.Queue(data, OnReadInJob);
}
private void OnReadInJob(object state, StatusCode error)
{
if (error.IsBad())
{
return;
}
ReadInJobData data = state as ReadInJobData;
DataValue dv = data.Callback(data.ReadValueId, data.OperationHandle.NodeHandle);
((ReadCompleteEventHandler)data.Transaction.Callback)(
data.OperationHandle,
data.Transaction.CallbackData,
dv,
false);
}
delegate DataValue ReadInJob(ReadValueId nodeToRead, NodeAttributeHandle nodeHandle);
DataValue OnReadSlowVariable(ReadValueId nodeToRead, NodeAttributeHandle nodeHandle)
{
SlowDataSourceWrapperSynchronous dataSourceWrapper = nodeHandle.UserData as SlowDataSourceWrapperSynchronous;
uint value = dataSourceWrapper.DataSource.ReadValue();
return new DataValue()
{
WrappedValue = new Variant(value),
SourceTimestamp = DateTime.UtcNow,
ServerTimestamp = DateTime.UtcNow
};
}
class ReadInJobData
{
public RequestContext Context { get; set; }
public TransactionHandle Transaction { get; set; }
public NodeAttributeOperationHandle OperationHandle { get; set; }
public ReadValueId ReadValueId { get; set; }
public ReadInJob Callback { get; set; }
}

Asynchronous

When using the asynchronous API, we can invoke the transaction callback in the callback of the data source:

var dataSourceWrapper = operationHandles[ii].NodeHandle.UserData as SlowDataSourceWrapperAsynchronous;
if (dataSourceWrapper != null)
{
dataSourceWrapper.DataSource.BeginRead(OnSlowAsynReadComplete, new ReadAsyncData()
{
Transaction = transaction,
OperationHandle = operationHandles[ii]
});
}
}
private void OnSlowAsynReadComplete(uint value, object userData)
{
ReadAsyncData data = userData as ReadAsyncData;
DataValue dv = new DataValue()
{
WrappedValue = new Variant(value),
SourceTimestamp = DateTime.UtcNow,
ServerTimestamp = DateTime.UtcNow
};
((ReadCompleteEventHandler)data.Transaction.Callback)(
data.OperationHandle,
data.Transaction.CallbackData,
dv,
false);
}
class ReadAsyncData
{
public TransactionHandle Transaction { get; set; }
public NodeAttributeOperationHandle OperationHandle { get; set; }
}

Write

The Write implementation is equivalent to the Read implementation and not therefore not shown in this documentation.

Monitoring

Even if the external data source is not event based and we can get current values only by calling a Read method, we must not set the NodeHandleType to ExternalPolled. Using this NodeHandleType could waste the threads of the ThreadPool (of the OPC UA server in the synchronous case or of the device in the asynchronous case). As a result, the server could block.

So we need to use the NodeHandleType ExternalPushed and do an own sampling of the data source. This sampling is done by the wrapper classes of the data source. If the first MonitoredItem is added to the wrapper, the sampling is started. If the last MonitoredItem is removed, the sampling is stopped again. Since we are using the NodeHandleType ExternalPushed we need to implement

  • StartDataMonitoring
  • ModifyDataMonitoring
  • StopDataMonitoring
  • SetDataMonitoringMode

In these methods, the MonitoredItems are added to and removed from the wrapper classes:

private DataMonitoringResult StartDataMonitoringSlowVariables(
RequestContext context,
MonitoredItemHandle itemHandle,
MonitoredItemCreateRequest settings,
DataChangeEventHandler callback)
{
DataMonitoringResult result = Server.ValidateDataMonitoringRequest(
context,
itemHandle.NodeHandle,
settings.ItemToMonitor,
settings.RequestedParameters,
null);
if (result.StatusCode.IsBad())
{
return result;
}
SlowDataSourceWrapper dataSourceWrapper = itemHandle.NodeHandle.UserData as SlowDataSourceWrapper;
dataSourceWrapper.AddMonitoredItem(new SlowDataSourceWrapperMonitoredItem()
{
ItemHandle = itemHandle,
MonitoringMode = settings.MonitoringMode,
SampligInterval = (int)settings.RequestedParameters.SamplingInterval,
Callback = callback
});
return result;
}
private DataMonitoringResult ModifyDataMonitoringSlowVariables(
RequestContext context,
MonitoredItemHandle itemHandle,
MonitoredItemModifyRequest settings)
{
// validate request.
DataMonitoringResult result = Server.ValidateDataMonitoringRequest(
context,
itemHandle.NodeHandle,
null,
settings.RequestedParameters,
null);
if (result.StatusCode.IsBad())
{
return result;
}
return result;
}
private StatusCode? StopDataMonitoringSlowVariables(
RequestContext context,
MonitoredItemHandle itemHandle)
{
SlowDataSourceWrapper dataSourceWrapper = itemHandle.NodeHandle.UserData as SlowDataSourceWrapper;
dataSourceWrapper.RemoveMonitoredItem(itemHandle);
return StatusCodes.Good;
}
private StatusCode? SetDataMonitoringModeSlowVariables(
RequestContext context,
MonitoredItemHandle itemHandle,
MonitoringMode monitoringMode,
MonitoringParameters parameters)
{
SlowDataSourceWrapper dataSourceWrapper = itemHandle.NodeHandle.UserData as SlowDataSourceWrapper;
bool succeeded = dataSourceWrapper.SetMonitoringMode(itemHandle, monitoringMode, parameters);
return StatusCodes.Good;
}
internal abstract class SlowDataSourceWrapper
{
public SlowDataSourceWrapper()
{
m_lock = new object();
}
public void AddMonitoredItem(SlowDataSourceWrapperMonitoredItem monitoredItem)
{
lock (m_lock)
{
if (m_monitoredItems == null)
{
m_monitoredItems = new List<SlowDataSourceWrapperMonitoredItem>();
}
m_monitoredItems.Add(monitoredItem);
int lastSamplingInterval = m_currentSamplingInterval;
UpdatePolling();
if (m_lastValue != null && m_currentSamplingInterval == lastSamplingInterval)
{
monitoredItem.Callback(null, monitoredItem.ItemHandle, m_lastValue, false);
}
else
{
monitoredItem.Callback(null, monitoredItem.ItemHandle, new DataValue(StatusCodes.BadWaitingForInitialData), false);
}
}
}
public bool RemoveMonitoredItem(MonitoredItemHandle itemHandle)
{
bool itemRemoved = false;
lock (m_lock)
{
if (m_monitoredItems == null)
{
return false;
}
foreach (SlowDataSourceWrapperMonitoredItem monitoredItem in m_monitoredItems)
{
if (monitoredItem.ItemHandle == itemHandle)
{
m_monitoredItems.Remove(monitoredItem);
itemRemoved = true;
break;
}
}
if (m_monitoredItems.Count == 0)
{
m_timer.Dispose();
m_timer = null;
m_instance = null;
m_lastValue = null;
m_currentSamplingInterval = 0;
}
else
{
UpdatePolling();
}
}
return itemRemoved;
}
public bool SetMonitoringMode(
MonitoredItemHandle itemHandle,
MonitoringMode monitoringMode,
MonitoringParameters parameters)
{
lock (m_lock)
{
foreach (var monitoredItem in m_monitoredItems)
{
if (monitoredItem.ItemHandle == itemHandle)
{
monitoredItem.SampligInterval = (int)parameters.SamplingInterval;
monitoredItem.MonitoringMode = monitoringMode;
UpdatePolling();
monitoredItem.Callback(null, monitoredItem.ItemHandle, m_lastValue, false);
return true;
}
}
}
return false;
}
protected void UpdatePolling()
{
lock (m_lock)
{
int samplingInterval = NewSamplingInterval();
if (samplingInterval < m_currentSamplingInterval || m_currentSamplingInterval == 0)
{
if (m_timer != null)
{
m_timer.Dispose();
m_lastValue = null;
}
m_instance = new object();
m_timer = new Timer(OnPoll, m_instance, 0, samplingInterval);
m_currentSamplingInterval = samplingInterval;
}
}
}
protected int NewSamplingInterval()
{
int newSamplingInterval = 5000;
foreach (SlowDataSourceWrapperMonitoredItem monitoredItem in m_monitoredItems)
{
newSamplingInterval = Math.Min(newSamplingInterval, (int)monitoredItem.SampligInterval);
}
if (newSamplingInterval <= 250)
{
return 250;
}
if (newSamplingInterval <= 1000)
{
return 1000;
}
return 5000;
}
protected abstract void OnPoll(object state);
protected object m_lock;
protected object m_instance;
protected Timer m_timer;
protected List<SlowDataSourceWrapperMonitoredItem> m_monitoredItems;
protected int m_currentSamplingInterval;
protected DataValue m_lastValue;
}
internal class SlowDataSourceWrapperSynchronous : SlowDataSourceWrapper
{
public SlowDataSourceWrapperSynchronous(SlowDataSourceSynchronousAPI dataSource)
{
m_dataSource = dataSource;
}
public SlowDataSourceSynchronousAPI DataSource
{
get
{
return m_dataSource;
}
}
protected override void OnPoll(object state)
{
uint value = m_dataSource.ReadValue();
lock (m_lock)
{
if (m_instance == state)
{
m_lastValue = new DataValue()
{
WrappedValue = new Variant(value),
SourceTimestamp = DateTime.UtcNow,
ServerTimestamp = DateTime.UtcNow
};
foreach (SlowDataSourceWrapperMonitoredItem monitoredItem in m_monitoredItems)
{
monitoredItem.Callback(null, monitoredItem.ItemHandle, m_lastValue, false);
}
}
}
}
private SlowDataSourceSynchronousAPI m_dataSource;
}
internal class SlowDataSourceWrapperAsynchronous : SlowDataSourceWrapper
{
public SlowDataSourceWrapperAsynchronous(SlowDataSourceAsynchronousAPI dataSource)
{
m_dataSource = dataSource;
}
public SlowDataSourceAsynchronousAPI DataSource
{
get
{
return m_dataSource;
}
}
protected override void OnPoll(object state)
{
m_dataSource.BeginRead(OnReadComplete, state);
}
void OnReadComplete(uint value, object userData)
{
lock (m_lock)
{
if (m_instance == userData)
{
m_lastValue = new DataValue()
{
WrappedValue = new Variant(value),
SourceTimestamp = DateTime.UtcNow,
ServerTimestamp = DateTime.UtcNow
};
foreach (SlowDataSourceWrapperMonitoredItem monitoredItem in m_monitoredItems)
{
monitoredItem.Callback(null, monitoredItem.ItemHandle, m_lastValue, false);
}
}
}
}
private SlowDataSourceAsynchronousAPI m_dataSource;
}
internal class SlowDataSourceWrapperMonitoredItem
{
public MonitoredItemHandle ItemHandle { get; set; }
public MonitoringMode MonitoringMode { get; set; }
public int SampligInterval { get; set; }
public DataChangeEventHandler Callback { get; set; }
}