diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
index 7184bc58..67d9ef55 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs
@@ -62,8 +62,10 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink
/// When true the node is created read/write; otherwise read-only.
/// null ⇒ not historized; non-null ⇒ create Historizing with the
/// HistoryRead access bit and register the historian tagname.
- public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null)
- => _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname);
+ /// When true the node is created as a 1-D array; when false (default) scalar.
+ /// The declared length of the 1-D array when is true.
+ public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
+ => _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength);
/// Rebuilds the address space through the inner sink.
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs
index 62469be8..237c5eeb 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs
@@ -71,7 +71,13 @@ public interface IOpcUaAddressSpaceSink
/// null ⇒ the variable is not historized; non-null ⇒ create it
/// Historizing with the HistoryRead access bit and register the (already default-resolved)
/// historian tagname.
- void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null);
+ /// When true the node is created as a 1-D array (ValueRank=OneDimension
+ /// with ArrayDimensions=[arrayLength]); when false (default) it stays scalar
+ /// (ValueRank=Scalar). Array elements share the scalar's base —
+ /// rank + dimensions carry the array-ness.
+ /// The declared length of the 1-D array when is
+ /// true; ignored for scalars. Null ⇒ length 0 (unbounded).
+ void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null);
///
/// Tear down + repopulate the address space. Called by OpcUaPublishActor after a
@@ -104,7 +110,7 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
///
- public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null) { }
+ public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
///
public void RebuildAddressSpace() { }
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index c17ac430..5d4f2e89 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -1296,7 +1296,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// HistoryRead access bit OR-ed into both AccessLevel and UserAccessLevel, and the
/// (already default-resolved) tagname is registered in the NodeId→tagname map the HistoryRead override
/// resolves against.
- public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null)
+ /// Phase 4c: when true the node is materialised as a 1-D array
+ /// (ValueRank=OneDimension with ArrayDimensions=[arrayLength]); when false (default) it
+ /// stays scalar (ValueRank=Scalar). Array elements share the scalar's base
+ /// — rank + dimensions carry the array-ness.
+ /// Phase 4c: the declared length of the 1-D array when
+ /// is true; ignored for scalars. Null ⇒ length 0.
+ public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
{
ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
ArgumentException.ThrowIfNullOrEmpty(displayName);
@@ -1324,7 +1330,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
ReferenceTypeId = ReferenceTypeIds.Organizes,
DataType = ResolveBuiltInDataType(dataType),
- ValueRank = ValueRanks.Scalar,
+ ValueRank = isArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
+ ArrayDimensions = isArray ? new ReadOnlyList(new UInt32Collection(new[] { arrayLength ?? 0u })) : null,
AccessLevel = access,
UserAccessLevel = access,
Historizing = historized,
@@ -2046,7 +2053,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
ReferenceTypeId = ReferenceTypeIds.Organizes,
DataType = DataTypeIds.BaseDataType,
- ValueRank = ValueRanks.Scalar,
+ // Lazy-created nodes (a WriteValue for a node never declared via EnsureVariable) carry no
+ // array intent, so they stay scalar with no ArrayDimensions — mirrors EnsureVariable's
+ // scalar branch. Array nodes are always pre-declared via EnsureVariable(isArray:true).
+ ValueRank = ValueRanks.Scalar,
+ ArrayDimensions = null,
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead,
Historizing = false,
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
index 6357f31c..9894e33b 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs
@@ -60,8 +60,10 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink
/// When true the node is created read/write; otherwise read-only.
/// null ⇒ not historized; non-null ⇒ create Historizing with the
/// HistoryRead access bit and register the historian tagname.
- public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null)
- => _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname);
+ /// When true the node is created as a 1-D array; when false (default) scalar.
+ /// The declared length of the 1-D array when is true.
+ public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
+ => _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength);
/// Rebuilds the entire OPC UA address space.
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
index 644ac4c4..d4df9737 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/DeferredAddressSpaceSinkTests.cs
@@ -115,7 +115,7 @@ public sealed class DeferredAddressSpaceSinkTests
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> CallQueue.Enqueue($"EF:{folderNodeId}");
///
- public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null)
+ public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
{
CallQueue.Enqueue($"EV:{variableNodeId}");
HistorianQueue.Enqueue((variableNodeId, historianTagname));
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerArrayTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerArrayTests.cs
new file mode 100644
index 00000000..fb03a669
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerArrayTests.cs
@@ -0,0 +1,123 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Opc.Ua;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
+
+///
+/// Phase 4c Task 1 — node-manager materialisation honours the array intent. Boot a real
+/// through (the same harness
+/// uses), drive
+/// with / without the new isArray /
+/// arrayLength params, and assert the created 's
+/// ValueRank + ArrayDimensions. Also proves the existing value-write path already
+/// round-trips a CLR array with no change.
+///
+public sealed class NodeManagerArrayTests : IDisposable
+{
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private readonly string _pkiRoot = Path.Combine(
+ Path.GetTempPath(),
+ $"otopcua-array-{Guid.NewGuid():N}");
+
+ /// An array variable is created with ValueRank=OneDimension and a single-element
+ /// ArrayDimensions carrying the requested length.
+ [Fact]
+ public async Task EnsureVariable_with_isArray_sets_one_dimension_rank_and_array_dimensions()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+
+ nm.EnsureVariable("eq-1/arr", parentFolderNodeId: null, displayName: "arr", dataType: "Int32",
+ writable: false, historianTagname: null, isArray: true, arrayLength: 8);
+
+ var variable = nm.TryGetVariable("eq-1/arr");
+ variable.ShouldNotBeNull();
+ variable!.ValueRank.ShouldBe(ValueRanks.OneDimension);
+ variable.ArrayDimensions.ShouldNotBeNull();
+ variable.ArrayDimensions.ShouldBe(new uint[] { 8u });
+
+ await host.DisposeAsync();
+ }
+
+ /// A default (scalar) EnsureVariable call keeps ValueRank=Scalar and leaves
+ /// ArrayDimensions null/empty.
+ [Fact]
+ public async Task EnsureVariable_default_is_scalar_with_no_array_dimensions()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+
+ nm.EnsureVariable("eq-1/scalar", parentFolderNodeId: null, displayName: "scalar", dataType: "Int32",
+ writable: false);
+
+ var variable = nm.TryGetVariable("eq-1/scalar");
+ variable.ShouldNotBeNull();
+ variable!.ValueRank.ShouldBe(ValueRanks.Scalar);
+ (variable.ArrayDimensions is null || variable.ArrayDimensions.Count == 0).ShouldBeTrue();
+
+ await host.DisposeAsync();
+ }
+
+ /// The existing WriteValue path round-trips a CLR array onto an array node with no change:
+ /// after EnsureVariable(isArray:true), WriteValue(int[]) surfaces the array verbatim with Good status.
+ [Fact]
+ public async Task WriteValue_round_trips_a_clr_array_onto_an_array_node()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+
+ nm.EnsureVariable("eq-1/arrwrite", parentFolderNodeId: null, displayName: "arrwrite", dataType: "Int32",
+ writable: false, historianTagname: null, isArray: true, arrayLength: 3);
+
+ var payload = new[] { 1, 2, 3 };
+ nm.WriteValue("eq-1/arrwrite", payload, OpcUaQuality.Good, DateTime.UtcNow);
+
+ var variable = nm.TryGetVariable("eq-1/arrwrite");
+ variable.ShouldNotBeNull();
+ variable!.Value.ShouldBe(payload);
+ variable.StatusCode.ShouldBe((StatusCode)StatusCodes.Good);
+
+ await host.DisposeAsync();
+ }
+
+ private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
+ {
+ var host = new OpcUaApplicationHost(
+ new OpcUaApplicationHostOptions
+ {
+ ApplicationName = "OtOpcUa.ArrayTest",
+ ApplicationUri = $"urn:OtOpcUa.ArrayTest:{Guid.NewGuid():N}",
+ OpcUaPort = AllocateFreePort(),
+ PublicHostname = "localhost",
+ PkiStoreRoot = _pkiRoot,
+ },
+ NullLogger.Instance);
+
+ var server = new OtOpcUaSdkServer();
+ await host.StartAsync(server, Ct);
+ return (host, server);
+ }
+
+ private static int AllocateFreePort()
+ {
+ using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+
+ /// Cleans up the PKI root directory.
+ public void Dispose()
+ {
+ if (Directory.Exists(_pkiRoot))
+ {
+ try { Directory.Delete(_pkiRoot, recursive: true); }
+ catch { /* best-effort cleanup */ }
+ }
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs
index 1d8affdb..0dbaf7db 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierHierarchyTests.cs
@@ -266,7 +266,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
/// The OPC UA built-in type name.
/// Whether the node is created read/write.
/// The resolved historian tagname (null ⇒ not historized).
- public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null) { }
+ public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
/// Rebuilds the address space (stub implementation for testing).
public void RebuildAddressSpace() { }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
index 42d7a3db..14925d81 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ApplierTests.cs
@@ -768,7 +768,7 @@ public sealed class Phase7ApplierTests
/// The OPC UA built-in type name.
/// Whether the node is created read/write.
/// The resolved historian tagname (null ⇒ not historized).
- public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null)
+ public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
{
VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
HistorianQueue.Enqueue((variableNodeId, historianTagname));
@@ -818,7 +818,7 @@ public sealed class Phase7ApplierTests
/// The OPC UA built-in type name.
/// Whether the node is created read/write.
/// The resolved historian tagname (null ⇒ not historized).
- public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null) { }
+ public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
/// No-op rebuild address space call.
public void RebuildAddressSpace() { }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs
index 44432435..06b5de9f 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Observability/OtOpcUaTelemetryHookTests.cs
@@ -219,7 +219,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
/// The OPC UA built-in type name.
/// Whether the node is created read/write.
/// The resolved historian tagname (null ⇒ not historized).
- public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null) { }
+ public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
/// Rebuilds address space (recorded via span).
public void RebuildAddressSpace() { /* recorded via span */ }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs
index eeb45342..74341fd9 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs
@@ -289,7 +289,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
/// The OPC UA built-in type name.
/// Whether the node is created read/write.
/// The resolved historian tagname (null ⇒ not historized).
- public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null)
+ public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
=> Calls.Enqueue($"EV:{variableNodeId}");
/// Records a rebuild address space call.
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs
index e0368ba3..77696819 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs
@@ -592,7 +592,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
/// The OPC UA built-in type name.
/// Whether the node is created read/write.
/// The resolved historian tagname (null ⇒ not historized).
- public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null) { }
+ public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
/// Records a rebuild call.
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);