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>
|
||||
|
||||
Reference in New Issue
Block a user