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