feat(dcl): surface OPC UA DataType/ValueRank/Writable on BrowseNode (T16 type-info)

This commit is contained in:
Joseph Doherty
2026-06-18 02:02:23 -04:00
parent bf1f2f6892
commit 5fd77c7155
3 changed files with 270 additions and 1 deletions
@@ -30,11 +30,17 @@ public record BrowseChildrenResult(
/// <param name="DisplayName">Human-readable display name from the server's DisplayName attribute.</param>
/// <param name="NodeClass">Classifies the node for UI purposes (Variable rows are selectable; Object rows are navigable).</param>
/// <param name="HasChildren">Hint so the UI can render an expand chevron without a second roundtrip.</param>
/// <param name="DataType">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).</param>
/// <param name="ValueRank">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.</param>
/// <param name="Writable">True when the node grants CurrentWrite to the calling user; null when not a Variable or the type read failed.</param>
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 }
@@ -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<List<Commons.Interfaces.Protocol.BrowseNode>> EnrichVariableTypeInfoAsync(
ISession session,
ReferenceDescriptionCollection refs,
List<Commons.Interfaces.Protocol.BrowseNode> 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<int>();
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;
}
}
}
/// <summary>
/// T16 type-info: maps well-known OPC UA built-in <c>DataType</c> NodeIds
/// (namespace 0 numeric ids in <see cref="DataTypeIds"/>) 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.
/// </summary>
internal static class OpcUaBuiltInTypeNames
{
private static readonly IReadOnlyDictionary<NodeId, string> Names = new Dictionary<NodeId, string>
{
[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"
};
/// <summary>
/// Resolves a DataType NodeId to a friendly built-in name, or the NodeId
/// string for unknown / vendor types. Null returns <see cref="string.Empty"/>.
/// </summary>
/// <param name="dataTypeNodeId">The DataType attribute value read from the server, or null.</param>
/// <returns>Friendly name for built-ins; NodeId string for unknowns; empty string for null.</returns>
public static string Resolve(NodeId? dataTypeNodeId)
{
if (dataTypeNodeId is null)
{
return string.Empty;
}
return Names.TryGetValue(dataTypeNodeId, out var name)
? name
: dataTypeNodeId.ToString();
}
}
/// <summary>
@@ -0,0 +1,83 @@
using Opc.Ua;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <summary>
/// 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.
/// </summary>
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));
}
}