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);