feat(dcl): surface OPC UA DataType/ValueRank/Writable on BrowseNode (T16 type-info)
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user