diff --git a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs index 8d14e75..2ea77dc 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxNodeManager.cs @@ -27,10 +27,18 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa // Ref-counted MXAccess subscriptions private readonly Dictionary _subscriptionRefCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _tagToVariableNode = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _tagMetadata = new Dictionary(StringComparer.OrdinalIgnoreCase); private readonly object _lock = new object(); private IDictionary>? _externalReferences; + private sealed class TagMetadata + { + public int MxDataType { get; set; } + public bool IsArray { get; set; } + public int? ArrayDimension { get; set; } + } + /// /// Gets the mapping from OPC UA node identifiers to the Galaxy tag references used for runtime I/O. /// @@ -91,6 +99,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { _nodeIdToTagReference.Clear(); _tagToVariableNode.Clear(); + _tagMetadata.Clear(); VariableNodeCount = 0; ObjectNodeCount = 0; @@ -198,6 +207,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa PredefinedNodes.Clear(); _nodeIdToTagReference.Clear(); _tagToVariableNode.Clear(); + _tagMetadata.Clear(); _subscriptionRefCounts.Clear(); // Rebuild @@ -266,12 +276,19 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa variable.AccessLevel = AccessLevels.CurrentReadOrWrite; variable.UserAccessLevel = AccessLevels.CurrentReadOrWrite; + variable.Value = NormalizePublishedValue(attr.FullTagReference, null); variable.StatusCode = StatusCodes.BadWaitingForInitialData; variable.Timestamp = DateTime.UtcNow; AddPredefinedNode(SystemContext, variable); _nodeIdToTagReference[nodeIdString] = attr.FullTagReference; _tagToVariableNode[attr.FullTagReference] = variable; + _tagMetadata[attr.FullTagReference] = new TagMetadata + { + MxDataType = attr.MxDataType, + IsArray = attr.IsArray, + ArrayDimension = attr.ArrayDimension + }; VariableNodeCount++; } @@ -372,7 +389,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa try { var vtq = _mxAccessClient.ReadAsync(tagRef).GetAwaiter().GetResult(); - results[i] = DataValueConverter.FromVtq(vtq); + results[i] = CreatePublishedDataValue(tagRef, vtq); errors[i] = ServiceResult.Good; } catch (Exception ex) @@ -446,7 +463,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa if (!int.TryParse(indexRange, out var index) || index < 0) return false; - var currentValue = _mxAccessClient.ReadAsync(tagRef).GetAwaiter().GetResult().Value; + var currentValue = NormalizePublishedValue(tagRef, _mxAccessClient.ReadAsync(tagRef).GetAwaiter().GetResult().Value); if (currentValue is not Array currentArray || currentArray.Rank != 1 || index >= currentArray.Length) return false; @@ -491,13 +508,47 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa if (!_tagToVariableNode.TryGetValue(tagRef, out var variable)) return; - var dataValue = DataValueConverter.FromVtq(Vtq.Good(value)); + var dataValue = CreatePublishedDataValue(tagRef, Vtq.Good(value)); variable.Value = dataValue.Value; variable.StatusCode = dataValue.StatusCode; variable.Timestamp = dataValue.SourceTimestamp; variable.ClearChangeMasks(SystemContext, false); } + private DataValue CreatePublishedDataValue(string tagRef, Vtq vtq) + { + var normalizedValue = NormalizePublishedValue(tagRef, vtq.Value); + if (ReferenceEquals(normalizedValue, vtq.Value)) + return DataValueConverter.FromVtq(vtq); + + return DataValueConverter.FromVtq(new Vtq(normalizedValue, vtq.Timestamp, vtq.Quality)); + } + + private object? NormalizePublishedValue(string tagRef, object? value) + { + if (value != null) + return value; + + if (!_tagMetadata.TryGetValue(tagRef, out var metadata) || !metadata.IsArray || !metadata.ArrayDimension.HasValue) + return null; + + return CreateDefaultArrayValue(metadata); + } + + private static Array CreateDefaultArrayValue(TagMetadata metadata) + { + var elementType = MxDataTypeMapper.MapToClrType(metadata.MxDataType); + var values = Array.CreateInstance(elementType, metadata.ArrayDimension!.Value); + + if (elementType == typeof(string)) + { + for (int i = 0; i < values.Length; i++) + values.SetValue(string.Empty, i); + } + + return values; + } + #endregion #region Subscription Delivery @@ -588,7 +639,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa { try { - var dataValue = DataValueConverter.FromVtq(vtq); + var dataValue = CreatePublishedDataValue(address, vtq); variable.Value = dataValue.Value; variable.StatusCode = dataValue.StatusCode; variable.Timestamp = dataValue.SourceTimestamp; diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/ArrayWriteTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/ArrayWriteTests.cs index 4c88b8a..d5af7c9 100644 --- a/tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/ArrayWriteTests.cs +++ b/tests/ZB.MOM.WW.LmxOpcUa.Tests/Integration/ArrayWriteTests.cs @@ -90,6 +90,37 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration } } + /// + /// Confirms that a null runtime value for a statically sized array is exposed as a typed fixed-length array. + /// + [Fact] + public async Task Read_NullStaticArray_ReturnsDefaultTypedArray() + { + var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); + fixture.MxAccessClient!.TagValues["MESReceiver_001.MoveInPartNumbers[]"] = Vtq.Good(null); + + await fixture.InitializeAsync(); + try + { + using var client = new OpcUaTestClient(); + await client.ConnectAsync(fixture.EndpointUrl); + + var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers"); + + var value = client.Read(nodeId).Value as string[]; + value.ShouldNotBeNull(); + value.Length.ShouldBe(50); + value.ShouldAllBe(v => v == string.Empty); + + var dimensions = client.ReadAttribute(nodeId, Attributes.ArrayDimensions).Value as uint[]; + dimensions.ShouldBe(new uint[] { 50 }); + } + finally + { + await fixture.DisposeAsync(); + } + } + /// /// Confirms that an indexed write also updates the published OPC UA value seen by subscribed clients. /// @@ -136,5 +167,37 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Integration await fixture.DisposeAsync(); } } + + /// + /// Confirms that indexed writes succeed even when the current runtime array value is null. + /// + [Fact] + public async Task Write_SingleArrayElement_WhenCurrentArrayIsNull_UsesDefaultArray() + { + var fixture = OpcUaServerFixture.WithFakeMxAccessClient(); + fixture.MxAccessClient!.TagValues["MESReceiver_001.MoveInPartNumbers[]"] = Vtq.Good(null); + + await fixture.InitializeAsync(); + try + { + using var client = new OpcUaTestClient(); + await client.ConnectAsync(fixture.EndpointUrl); + + var nodeId = client.MakeNodeId("MESReceiver_001.MoveInPartNumbers"); + var status = client.Write(nodeId, new[] { "UPDATED-PART" }, indexRange: "1"); + StatusCode.IsGood(status).ShouldBe(true); + + var after = client.Read(nodeId).Value as string[]; + after.ShouldNotBeNull(); + after.Length.ShouldBe(50); + after[0].ShouldBe(string.Empty); + after[1].ShouldBe("UPDATED-PART"); + after[2].ShouldBe(string.Empty); + } + finally + { + await fixture.DisposeAsync(); + } + } } }