diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs index 740af83a..87f953af 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs @@ -30,11 +30,17 @@ public record BrowseChildrenResult( /// Human-readable display name from the server's DisplayName attribute. /// Classifies the node for UI purposes (Variable rows are selectable; Object rows are navigable). /// Hint so the UI can render an expand chevron without a second roundtrip. +/// Friendly built-in DataType name for Variable nodes (e.g. "Double", "Int32"); the NodeId string for vendor types; null when not a Variable or the type read failed (best-effort). +/// OPC UA ValueRank for Variable nodes (-1 = scalar, 0 = one-or-more dims, >0 = fixed array rank); null when not a Variable or the type read failed. +/// True when the node grants CurrentWrite to the calling user; null when not a Variable or the type read failed. public record BrowseNode( string NodeId, string DisplayName, BrowseNodeClass NodeClass, - bool HasChildren); + bool HasChildren, + string? DataType = null, + int? ValueRank = null, + bool? Writable = null); public enum BrowseNodeClass { Object, Variable, Method, Other } diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs index b40d2d44..aa65b2c0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs @@ -755,6 +755,15 @@ public class RealOpcUaClient : IOpcUaClient HasChildren: r.NodeClass == NodeClass.Object)); } + // T16 type-info: enrich Variable rows with DataType / ValueRank / + // Writable so the node picker can show types. Best-effort: ONE batched + // ReadAsync over (DataType, ValueRank, UserAccessLevel) for every + // Variable child; on ANY failure we leave the three fields null and + // return the children exactly as built above. Non-Variable nodes are + // never read and keep null type info. + children = await EnrichVariableTypeInfoAsync(session, refs, children, cancellationToken) + .ConfigureAwait(false); + // A non-empty continuation point means the server had more refs than // our requestedMaxReferencesPerNode cap. The UI surfaces a "more // children, type the node id manually" hint rather than auto-paging; @@ -771,6 +780,177 @@ public class RealOpcUaClient : IOpcUaClient NodeClass.Method => Commons.Interfaces.Protocol.BrowseNodeClass.Method, _ => Commons.Interfaces.Protocol.BrowseNodeClass.Other }; + + // T16 type-info: best-effort enrichment of Variable rows with DataType, + // ValueRank, and Writable. Reads all three attributes for every Variable + // child in ONE ReadAsync round-trip; on any failure (or zero variables) + // returns the input children unchanged. Caller has already built the + // BrowseNode list, so a swallowed failure simply means no type columns. + private async Task> EnrichVariableTypeInfoAsync( + ISession session, + ReferenceDescriptionCollection refs, + List children, + CancellationToken cancellationToken) + { + try + { + // Build a flat read of (DataType, ValueRank, UserAccessLevel) per + // Variable child, remembering which children-list slot each triple + // maps back to so the read values can be re-stitched in order. + var readIds = new ReadValueIdCollection(); + var variableSlots = new List(); + for (var i = 0; i < refs.Count; i++) + { + if (refs[i].NodeClass != NodeClass.Variable) + { + continue; + } + + var nodeId = ExpandedNodeId.ToNodeId(refs[i].NodeId, session.NamespaceUris); + if (nodeId is null) + { + continue; + } + + variableSlots.Add(i); + readIds.Add(new ReadValueId { NodeId = nodeId, AttributeId = Attributes.DataType }); + readIds.Add(new ReadValueId { NodeId = nodeId, AttributeId = Attributes.ValueRank }); + readIds.Add(new ReadValueId { NodeId = nodeId, AttributeId = Attributes.UserAccessLevel }); + } + + if (variableSlots.Count == 0) + { + return children; + } + + var readResponse = await session.ReadAsync( + null, + 0, + TimestampsToReturn.Neither, + readIds, + cancellationToken).ConfigureAwait(false); + + var results = readResponse.Results; + if (results is null || results.Count != readIds.Count) + { + // Defensive: a non-conformant server. Skip enrichment entirely + // rather than risk mis-aligning values to the wrong nodes. + return children; + } + + for (var v = 0; v < variableSlots.Count; v++) + { + var baseIdx = v * 3; + var dataTypeResult = results[baseIdx]; + var valueRankResult = results[baseIdx + 1]; + var accessLevelResult = results[baseIdx + 2]; + + string? dataType = null; + if (StatusCode.IsGood(dataTypeResult.StatusCode) && dataTypeResult.Value is NodeId dtNodeId) + { + dataType = OpcUaBuiltInTypeNames.Resolve(dtNodeId); + } + + int? valueRank = null; + if (StatusCode.IsGood(valueRankResult.StatusCode) && valueRankResult.Value is int vr) + { + valueRank = vr; + } + + bool? writable = null; + if (StatusCode.IsGood(accessLevelResult.StatusCode) && accessLevelResult.Value is byte accessLevel) + { + writable = ((AccessLevelType)accessLevel).HasFlag(AccessLevelType.CurrentWrite); + } + + var slot = variableSlots[v]; + children[slot] = children[slot] with + { + DataType = dataType, + ValueRank = valueRank, + Writable = writable + }; + } + + return children; + } + catch (OperationCanceledException) + { + // Honour cancellation — the caller's BrowseAsync already completed, + // but a cancelled type-read should propagate, not be swallowed as a + // best-effort miss. + throw; + } + catch (Exception ex) + { + // Best-effort: any other failure (server quirk, transient read + // error, type surprise) must NOT fail the browse. Return the + // children as originally built, with null type info. + _logger.LogDebug(ex, "Best-effort OPC UA variable type-info read failed; returning browse results without type columns."); + return children; + } + } +} + +/// +/// T16 type-info: maps well-known OPC UA built-in DataType NodeIds +/// (namespace 0 numeric ids in ) to friendly, +/// CLR-flavoured names for display in the node picker. Vendor / structured +/// DataTypes (anything not in the built-in table) fall back to the NodeId +/// string — the only thing meaningful the UI can render for an opaque type. +/// A null input yields the empty string so a missing DataType attribute on a +/// best-effort browse read never throws. +/// +internal static class OpcUaBuiltInTypeNames +{ + private static readonly IReadOnlyDictionary Names = new Dictionary + { + [DataTypeIds.Boolean] = "Boolean", + [DataTypeIds.SByte] = "SByte", + [DataTypeIds.Byte] = "Byte", + [DataTypeIds.Int16] = "Int16", + [DataTypeIds.UInt16] = "UInt16", + [DataTypeIds.Int32] = "Int32", + [DataTypeIds.UInt32] = "UInt32", + [DataTypeIds.Int64] = "Int64", + [DataTypeIds.UInt64] = "UInt64", + [DataTypeIds.Float] = "Float", + [DataTypeIds.Double] = "Double", + [DataTypeIds.String] = "String", + [DataTypeIds.DateTime] = "DateTime", + [DataTypeIds.Guid] = "Guid", + [DataTypeIds.ByteString] = "ByteString", + [DataTypeIds.XmlElement] = "XmlElement", + [DataTypeIds.NodeId] = "NodeId", + [DataTypeIds.ExpandedNodeId] = "ExpandedNodeId", + [DataTypeIds.StatusCode] = "StatusCode", + [DataTypeIds.QualifiedName] = "QualifiedName", + [DataTypeIds.LocalizedText] = "LocalizedText", + [DataTypeIds.DataValue] = "DataValue", + [DataTypeIds.Number] = "Number", + [DataTypeIds.Integer] = "Integer", + [DataTypeIds.UInteger] = "UInteger", + [DataTypeIds.Enumeration] = "Enumeration", + [DataTypeIds.BaseDataType] = "BaseDataType" + }; + + /// + /// Resolves a DataType NodeId to a friendly built-in name, or the NodeId + /// string for unknown / vendor types. Null returns . + /// + /// The DataType attribute value read from the server, or null. + /// Friendly name for built-ins; NodeId string for unknowns; empty string for null. + public static string Resolve(NodeId? dataTypeNodeId) + { + if (dataTypeNodeId is null) + { + return string.Empty; + } + + return Names.TryGetValue(dataTypeNodeId, out var name) + ? name + : dataTypeNodeId.ToString(); + } } /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaDataTypeNameMapTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaDataTypeNameMapTests.cs new file mode 100644 index 00000000..ddea1814 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaDataTypeNameMapTests.cs @@ -0,0 +1,83 @@ +using Opc.Ua; +using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; + +/// +/// M7-B1 (T16 type-info): pure NodeId → friendly built-in DataType name mapping. +/// Known OPC UA built-in DataType NodeIds (ns=0;i=…) resolve to their CLR-ish +/// friendly names; unknown/custom DataType NodeIds fall back to the NodeId string. +/// No live server is required — this is a pure lookup helper. +/// +public class OpcUaDataTypeNameMapTests +{ + [Theory] + [InlineData("Boolean")] + [InlineData("SByte")] + [InlineData("Byte")] + [InlineData("Int16")] + [InlineData("UInt16")] + [InlineData("Int32")] + [InlineData("UInt32")] + [InlineData("Int64")] + [InlineData("UInt64")] + [InlineData("Float")] + [InlineData("Double")] + [InlineData("String")] + [InlineData("DateTime")] + [InlineData("Guid")] + [InlineData("ByteString")] + public void Resolve_KnownBuiltInTypes_ReturnFriendlyNames(string typeName) + { + // Look up the well-known DataType NodeId by its standard name on + // DataTypeIds (e.g. DataTypeIds.Double) so the test mirrors exactly + // the constants the helper maps against. + var field = typeof(DataTypeIds).GetField(typeName)!; + var nodeId = (NodeId)field.GetValue(null)!; + + Assert.Equal(typeName, OpcUaBuiltInTypeNames.Resolve(nodeId)); + } + + [Fact] + public void Resolve_Double_ReturnsDouble() + { + Assert.Equal("Double", OpcUaBuiltInTypeNames.Resolve(DataTypeIds.Double)); + } + + [Fact] + public void Resolve_Int32_ReturnsInt32() + { + Assert.Equal("Int32", OpcUaBuiltInTypeNames.Resolve(DataTypeIds.Int32)); + } + + [Fact] + public void Resolve_Boolean_ReturnsBoolean() + { + Assert.Equal("Boolean", OpcUaBuiltInTypeNames.Resolve(DataTypeIds.Boolean)); + } + + [Fact] + public void Resolve_String_ReturnsString() + { + Assert.Equal("String", OpcUaBuiltInTypeNames.Resolve(DataTypeIds.String)); + } + + [Fact] + public void Resolve_UnknownCustomNodeId_ReturnsNodeIdString() + { + // A vendor-defined DataType (string identifier, non-zero namespace) is + // not a built-in, so the helper falls back to the NodeId string — the + // only thing the UI can meaningfully display for an opaque type. + var custom = new NodeId("CustomType", 5); + + Assert.Equal(custom.ToString(), OpcUaBuiltInTypeNames.Resolve(custom)); + } + + [Fact] + public void Resolve_Null_ReturnsEmptyString() + { + // Defensive: a null DataType attribute value must not throw during a + // best-effort browse type-read. + Assert.Equal(string.Empty, OpcUaBuiltInTypeNames.Resolve(null)); + } +}