Overview
Developers building an OPC UA server with the Unified Automation .NET SDK frequently encounter a problem when the underlying control system exposes data as a System.Collections.ArrayList of indeterminate length. Unlike scalar tags, a variable backed by a dynamic list must be modeled so that an OPC UA client can read it correctly even when the number of elements grows or shrinks between notifications. The solution combines three configuration items on the variable node (ValueRank, ArrayDimensions, and the underlying DataType) with the correct override of BaseNodeManager.Write for the ExternalPush pattern.
This reference documents the array semantics defined in the OPC UA specification, the role of the IIOManager / BaseNodeManager plumbing, and the exact sequence required to push an unknown-length array from the embedded system into the address space.
Prerequisites
- Unified Automation UaSdkNetServer bundle (C++/.NET Server SDK) installed and licensed.
- UaModeler for address space design, or an equivalent code-first builder.
- Visual Studio 2019 or later with .NET Framework 4.7.2 / .NET 6.0+ reference assemblies.
- Familiarity with OPC UA Part 3 address space concepts (variable nodes, data types, value rank). See the OPC UA Part 3 – Address Space specification.
- A working OPC UA client such as UaExpert for verification.
OPC UA Array Semantics: ValueRank and ArrayDimensions
The OPC UA Part 3 specification defines the ValueRank attribute to describe whether a variable holds a scalar or an n-dimensional array. The relevant constants for dynamic arrays are summarized below.
| ValueRank | Meaning | Typical Use |
|---|---|---|
| -2 (ScalarOrArray) | Either scalar or 1-D array | Loose-typed parameters |
| -1 (Any) | Scalar or any-dimension array | Generic Variables |
| 0 | Scalar value only | Single tag value |
| 1 | One-dimensional array | Dynamic lists, ArrayList data |
| n > 1 | n-dimensional array | Matrices, image data |
The ArrayDimensions attribute is a UInt32[] that declares the length of each dimension. A value of 0 for an entry signals "unknown length" to the client. For an ArrayList whose size is determined at runtime, the correct configuration is:
-
ValueRank =
1(one-dimensional array) -
ArrayDimensions =
{ 0 }(length unknown, dynamic) -
DataType = the underlying scalar type (e.g.
Int32,Double,String), not a complex type -
Value attribute starts as
nullor an empty array
Value attribute to determine the active array length. Pre-sizing to an arbitrary maximum (e.g. {1024}) forces every read to allocate that buffer; {0} tells the client "ask for the size each time", which matches the ArrayList semantics.Understanding the BaseNodeManager.Write Signature
When the underlying system uses the ExternalPush pattern (the I/O is event-driven rather than sampled), the SDK hands every incoming client write, and every internal server-side write, to the user-defined BaseNodeManager subclass. The override signature is:
public override ServiceResult Write(
RequestContext context,
NodeAttributeHandle nodeHandle,
NumericRange indexRange,
DataValue value,
TypeInfo typeInfo)
| Parameter | Purpose |
|---|---|
context |
Identifies the calling session, the secure channel, and the request diagnostic context. In ExternalPush the caller is the SDK itself; context.Session may be null. Use it to log who initiated the change, never to identify the target node. |
nodeHandle |
Opaque token returned earlier by CreateNode. The IIOManager stores these in a hash table keyed by NodeId. Cast to your derived NodeAttributeHandle to retrieve the original VariableNode reference. |
indexRange |
OPC UA NumericRange. For an array it selects a sub-range (e.g. "2:5" = elements 2..5 inclusive). When the client writes the whole array it is NumericRange.Empty. Use this when the underlying system can only deliver partial updates. |
value |
The DataValue to be applied. For an ArrayList pushed as int[], value.Value is a one-dimensional System.Array (typically Int32[]). |
typeInfo |
Type descriptor pre-validated against the node's DataType, ValueRank, and ArrayDimensions. typeInfo.ValueRank should equal ValueRanks.OneDimension; typeInfo.BuiltInType should match the configured scalar type. |
Identifying the Target Node via NodeAttributeHandle
The NodeAttributeHandle returned by the SDK is a generic wrapper that hides the internal NodeId mapping. To resolve it back to your application object, store the original VariableNode (or a thin wrapper) inside the handle at the time of CreateNode:
protected override NodeAttributeHandle CreateNode(
RequestContext context,
NodeId nodeId,
NodeClass nodeClass,
QualifiedName browseName,
ExtensibleObject baseAttribute,
NodeAttributesDataItem[] dataItems,
out ServiceResult result)
{
result = ServiceResult.Good;
if (nodeClass == NodeClass.Variable &&
_dynamicArrays.TryGetValue(nodeId, out var dyn))
{
return new DynamicArrayHandle(nodeId, dyn);
}
return base.CreateNode(context, nodeId, nodeClass,
browseName, baseAttribute,
dataItems, out result);
}
Inside Write the resolution looks like:
if (nodeHandle is DynamicArrayHandle dah)
{
VariableNode target = dah.Variable;
// target.DataType -> BuiltInType.Int32
// target.ValueRank == ValueRanks.OneDimension
// target.ArrayDimensions == { 0 }
}
Step-by-Step: Pushing an ArrayList with ExternalPush
The following procedure wires the embedded system event OnDataReady(ArrayList payload) to the OPC UA variable.
Step 1 – Model the variable in UaModeler
- Add a Variable node under your ObjectType.
- Set DataType to the scalar (e.g.
Int32). - Set ValueRank to
OneDimension(numeric value 1). - Set ArrayDimensions to a single-element array containing
0. - Compile to generate the C# boilerplate.
Step 2 – Register the node with the IIOManager
During server start-up, add the variable to your BaseNodeManager subclass so that reads and writes route through your override:
var v = new VariableNode
{
NodeId = new NodeId("Demo.ArrayListVar", ns),
BrowseName= new QualifiedName("ArrayListVar", ns),
DataType = new NodeId(DataTypes.Int32),
ValueRank = ValueRanks.OneDimension,
ArrayDimensions = new uint[] { 0 },
Value = new DataValue(new int[0])
};
_manager.AddNode(v);
_dynamicArrays[v.NodeId] = v;
Step 3 – Override BaseNodeManager.Write
public override ServiceResult Write(
RequestContext context,
NodeAttributeHandle nodeHandle,
NumericRange indexRange,
DataValue value,
TypeInfo typeInfo)
{
if (!(nodeHandle is DynamicArrayHandle dah))
return base.Write(context, nodeHandle, indexRange, value, typeInfo);
// 1. Validate type descriptor.
if (typeInfo == null ||
typeInfo.ValueRank != ValueRanks.OneDimension ||
typeInfo.BuiltInType != BuiltInType.Int32)
return StatusCodes.BadTypeMismatch;
// 2. Convert ArrayList to Int32[].
ArrayList payload = value.Value as ArrayList;
int[] native = payload == null
? (int[])value.Value
: payload.Cast<int>().ToArray();
// 3. Apply partial update if indexRange was supplied.
int[] working = (int[])dah.Variable.Value.Value;
if (indexRange != NumericRange.Empty)
{
// NumericRange.Begin..NumericRange.End is zero-based inclusive
int dst = indexRange.Begin;
int len = indexRange.End - indexRange.Begin + 1;
Array.Copy(native, 0, working, dst, Math.Min(len, native.Length));
native = working;
}
// 4. Commit & notify.
dah.Variable.Value = new DataValue(new Variant(native));
dah.Variable.ClearChangeMasks(context, true);
ReportValueChanged(dah.Variable);
return ServiceResult.Good;
}
Step 4 – Override BaseNodeManager.Read
The Read mirror must hand the cached array back to clients with the correct TypeInfo:
public override ServiceResult Read(
RequestContext context,
NodeAttributeHandle nodeHandle,
NumericRange indexRange,
TimestampsToReturn timestampsToReturn,
out DataValue value,
out TypeInfo typeInfo)
{
value = null;
typeInfo = null;
if (!(nodeHandle is DynamicArrayHandle dah))
return base.Read(context, nodeHandle, indexRange,
timestampsToReturn, out value, out typeInfo);
int[] native = (int[])dah.Variable.Value.Value;
value = new DataValue(new Variant(native));
typeInfo = new TypeInfo(BuiltInType.Int32, ValueRanks.OneDimension);
return ServiceResult.Good;
}
Step 5 – Push from the underlying system
The event handler is now a one-liner. Because the value is applied directly through Write, monitored items on the node fire automatically – the hallmark of the ExternalPush pattern:
private void Plc_OnDataReady(object sender, ArrayList payload)
{
var dv = new DataValue(new Variant(payload.ToArray(typeof(int))));
Write(RequestContext.Internal,
_handleForArrayListVar,
NumericRange.Empty,
dv,
new TypeInfo(BuiltInType.Int32, ValueRanks.OneDimension));
}
Verification
- Start the server, then connect UaExpert to
opc.tcp://localhost:48030. - Drag the
Demo.ArrayListVarnode into the Data Access view. - Confirm that the ValueRank column reads
1and ArrayDimensions reads{0}in the Attributes window. - Trigger a 5-element push from the embedded system; UaExpert should show 5 values.
- Push a 12-element payload; verify the client redraws with 12 values without re-subscribing.
- Subscribe with a MonitoredItem at sampling interval 0 (reporting on data change); confirm that every push generates exactly one notification – not one per element.
For end-to-end validation, see the array acquisition patterns described in the Rockwell Automation OPC UA white paper on data acquisition of arrays.
Troubleshooting Matrix
| Symptom | Likely Root Cause | Remediation |
|---|---|---|
BadTypeMismatch on every push |
ArrayDimensions pre-set to a fixed length, conflicting with the supplied array |
Change ArrayDimensions to {0}
|
| Client shows scalar, not array |
ValueRank = 0 |
Set ValueRank = OneDimension
|
| Notifications duplicated per element |
ReportValueChanged called inside a loop |
Call ReportValueChanged once per Write after the array is committed |
| Old data served after push |
IIOManager cache not invalidated |
Call ClearChangeMasks and ReportValueChanged in that order |
indexRange ignored |
NumericRange not applied to the cached array before storage | Apply Array.Copy into the destination slice as shown in Step 3 |
Variant contains object[] not int[]
|
ArrayList not converted to strongly-typed array | Use payload.ToArray(typeof(int)) before constructing the Variant |
Notes on Alternative Implementations
If the embedded system exposes a fixed-size array but only partially populated, set ArrayDimensions to the maximum length and have the server return the populated subset. If the variable is multi-dimensional (e.g. matrix data), use ValueRank = 2 and ArrayDimensions = {0, 0}; the OPC UA Part 3 specification defines the full NumericRange grammar for higher-dimensional slices.
FAQ
What does the indexRange parameter in BaseNodeManager.Write mean?
indexRange is a NumericRange string that selects a sub-section of an array, e.g. "3:7" selects elements 3 through 7 inclusive. When the client writes the whole array the parameter is NumericRange.Empty; when it writes a slice, you must apply the slice into your cached array before notifying clients.
How do I represent an unknown-length ArrayList in OPC UA?
Set the variable's ValueRank to 1 (one-dimensional array) and ArrayDimensions to a single-element array containing 0. The client treats {0} as "length determined at runtime", which matches System.Collections.ArrayList semantics.
Where does the NodeAttributeHandle originate?
The SDK creates one handle per attribute access during CreateNode. The handle is the lookup key into the IIOManager hash table; resolve it back to your VariableNode by casting to a derived handle type that stores the original reference.
Why is the value delivered as an object[] instead of int[]?
Variants from an ArrayList are stored as System.Object[] unless converted. Use payload.ToArray(typeof(int)) or payload.Cast<int>().ToArray() before constructing the DataValue, otherwise TypeInfo validation will fail with BadTypeMismatch.
Does the ExternalPush pattern require overriding both Read and Write?
Yes. Write is the entry point for pushes from the underlying system and for client writes; Read returns the cached array with the matching TypeInfo. Override both so that the SDK can convert between Variant, the type system, and your native storage without losing the array shape.