Auto: abcip-2.3 — descriptions to OPC UA Description

Threads tag/UDT-member descriptions captured by the L5K (#346) and L5X
(#347) parsers through AbCipTagDefinition + AbCipStructureMember into
DriverAttributeInfo, so the address-space builder sets the OPC UA
Description attribute on each Variable node. L5kMember and L5xParser
also now capture per-member descriptions (via the (Description := "...")
attribute block on L5K and the <Description> child on L5X), and
L5kIngest forwards them. DriverNodeManager surfaces
DriverAttributeInfo.Description as the Variable's Description property.

Description is added as a trailing optional parameter on
DriverAttributeInfo (default null) so every other driver continues
to construct the record unchanged.

Closes #231
This commit is contained in:
Joseph Doherty
2026-04-25 18:23:31 -04:00
parent e5b192fcb3
commit e5299cda5a
8 changed files with 248 additions and 11 deletions

View File

@@ -45,6 +45,13 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.ScriptedAlarm"/> —
/// stable logical id the ScriptedAlarmEngine addresses by. Null otherwise.
/// </param>
/// <param name="Description">
/// Human-readable description for this attribute. When non-null + non-empty the generic
/// node-manager surfaces the value as the OPC UA <c>Description</c> attribute on the
/// Variable node so SCADA / engineering clients see the field comment from the source
/// project (Studio 5000 tag descriptions, Galaxy attribute help text, etc.). Defaults to
/// null so drivers that don't carry descriptions are unaffected.
/// </param>
public sealed record DriverAttributeInfo(
string FullName,
DriverDataType DriverDataType,
@@ -56,7 +63,8 @@ public sealed record DriverAttributeInfo(
bool WriteIdempotent = false,
NodeSourceKind Source = NodeSourceKind.Driver,
string? VirtualTagId = null,
string? ScriptedAlarmId = null);
string? ScriptedAlarmId = null,
string? Description = null);
/// <summary>
/// Per ADR-002 — discriminates which runtime subsystem owns this node's Read/Write/

View File

@@ -959,7 +959,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: member.WriteIdempotent));
WriteIdempotent: member.WriteIdempotent,
Description: member.Description));
}
continue;
}
@@ -1019,7 +1020,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: tag.WriteIdempotent);
WriteIdempotent: tag.WriteIdempotent,
Description: tag.Description);
/// <summary>Count of registered devices — exposed for diagnostics + tests.</summary>
internal int DeviceCount => _devices.Count;

View File

@@ -120,6 +120,10 @@ public sealed record AbCipDeviceOptions(
/// and <c>GetString</c> / <c>SetString</c> truncate at the right boundary. <c>null</c>
/// keeps libplctag's default 82-byte STRING behaviour for back-compat. Ignored for
/// non-<see cref="AbCipDataType.String"/> types.</param>
/// <param name="Description">Tag description carried from the L5K/L5X export (or set explicitly
/// in pre-declared config). Surfaces as the OPC UA <c>Description</c> attribute on the
/// produced Variable node so SCADA / engineering clients see the comment from the source
/// project. <c>null</c> leaves Description unset, matching pre-2.3 behaviour.</param>
public sealed record AbCipTagDefinition(
string Name,
string DeviceHostAddress,
@@ -129,7 +133,8 @@ public sealed record AbCipTagDefinition(
bool WriteIdempotent = false,
IReadOnlyList<AbCipStructureMember>? Members = null,
bool SafetyTag = false,
int? StringLength = null);
int? StringLength = null,
string? Description = null);
/// <summary>
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>,
@@ -137,12 +142,18 @@ public sealed record AbCipTagDefinition(
/// <see cref="AbCipTagDefinition"/>. Declaration-driven — the real CIP Template Object reader
/// (class 0x6C) that would auto-discover member layouts lands as a follow-up PR.
/// </summary>
/// <remarks>
/// <see cref="Description"/> carries the per-member comment from L5K/L5X UDT definitions so
/// the OPC UA Variable nodes produced for individual members surface their descriptions too,
/// not just the top-level tag.
/// </remarks>
public sealed record AbCipStructureMember(
string Name,
AbCipDataType DataType,
bool Writable = true,
bool WriteIdempotent = false,
int? StringLength = null);
int? StringLength = null,
string? Description = null);
/// <summary>
/// One L5K-import entry. Either <see cref="FilePath"/> or <see cref="InlineText"/> must be

View File

@@ -55,7 +55,11 @@ public sealed class L5kIngest
var atomic = TryMapAtomic(m.DataType);
var memberType = atomic ?? AbCipDataType.Structure;
var writable = !IsReadOnly(m.ExternalAccess) && !IsAccessNone(m.ExternalAccess);
members.Add(new AbCipStructureMember(m.Name, memberType, writable));
members.Add(new AbCipStructureMember(
Name: m.Name,
DataType: memberType,
Writable: writable,
Description: m.Description));
}
udtIndex[dt.Name] = members;
}
@@ -101,7 +105,8 @@ public sealed class L5kIngest
TagPath: tagPath,
DataType: dataType,
Writable: writable,
Members: members));
Members: members,
Description: t.Description));
}
return new L5kIngestResult(tags, skippedAliases, skippedNoAccess);

View File

@@ -311,7 +311,8 @@ public static class L5kParser
if (typePart.Length == 0) return null;
var externalAccess = attributes.TryGetValue("ExternalAccess", out var ea) ? ea.Trim() : null;
return new L5kMember(name, typePart, arrayDim, externalAccess);
var description = attributes.TryGetValue("Description", out var d) ? Unquote(d) : null;
return new L5kMember(name, typePart, arrayDim, externalAccess, description);
}
// ---- helpers -----------------------------------------------------------
@@ -377,4 +378,9 @@ public sealed record L5kTag(
public sealed record L5kDataType(string Name, IReadOnlyList<L5kMember> Members);
/// <summary>One member line inside a UDT definition.</summary>
public sealed record L5kMember(string Name, string DataType, int? ArrayDim, string? ExternalAccess);
public sealed record L5kMember(
string Name,
string DataType,
int? ArrayDim,
string? ExternalAccess,
string? Description = null);

View File

@@ -163,11 +163,21 @@ public static class L5xParser
arrayDim = dim;
}
// Description child — same shape as on Tag nodes; sometimes wrapped in CDATA.
string? description = null;
var descNode = memberNode.SelectSingleNode("Description");
if (descNode is not null)
{
var raw = descNode.Value;
if (!string.IsNullOrEmpty(raw)) description = raw.Trim();
}
return new L5kMember(
Name: name,
DataType: dataType,
ArrayDim: arrayDim,
ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess);
ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess,
Description: description);
}
private static L5kDataType? ReadAddOnInstruction(XPathNavigator aoiNode)
@@ -200,11 +210,20 @@ public static class L5xParser
arrayDim = dim;
}
string? paramDescription = null;
var paramDescNode = paramNode.SelectSingleNode("Description");
if (paramDescNode is not null)
{
var raw = paramDescNode.Value;
if (!string.IsNullOrEmpty(raw)) paramDescription = raw.Trim();
}
members.Add(new L5kMember(
Name: paramName,
DataType: dataType,
ArrayDim: arrayDim,
ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess));
ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess,
Description: paramDescription));
}
return new L5kDataType(name, members);
}

View File

@@ -178,6 +178,13 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
NodeId = new NodeId(attributeInfo.FullName, NamespaceIndex),
BrowseName = new QualifiedName(browseName, NamespaceIndex),
DisplayName = new LocalizedText(displayName),
// Per Task #231 — surface the driver-supplied tag description as the OPC UA
// Description attribute on the Variable node. Drivers that don't carry
// descriptions pass null, leaving Description unset (the stack defaults to
// an empty LocalizedText, matching prior behaviour).
Description = string.IsNullOrEmpty(attributeInfo.Description)
? null
: new LocalizedText(attributeInfo.Description),
DataType = MapDataType(attributeInfo.DriverDataType),
ValueRank = attributeInfo.IsArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
// Historized attributes get the HistoryRead access bit so the stack dispatches