Pure-text parser for Studio 5000 L5K controller exports. Recognises TAG/END_TAG, DATATYPE/END_DATATYPE, and PROGRAM/END_PROGRAM blocks, strips (* ... *) comments, and tolerates multi-line entries + unknown sections (CONFIG, MOTION_GROUP, etc.). Output records — L5kTag, L5kDataType, L5kMember — feed L5kIngest which converts to AbCipTagDefinition + AbCipStructureMember. Alias tags and ExternalAccess=None tags are skipped per Kepware precedent. AbCipDriverOptions gains an L5kImports collection (AbCipL5kImportOptions records — file path or inline text + per-import device + name prefix). InitializeAsync merges the imports into the declared Tags map, with declared tags winning on Name conflicts so operators can override import results without editing the L5K source. Tests cover controller-scope TAG, program-scope TAG, alias-tag flag, DATATYPE with member array dims, comment stripping, unknown-section skipping, multi-line entries, and the full ingest path including ExternalAccess=None / ReadOnly / UDT-typed tag fanout. Closes #229 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
143 lines
6.4 KiB
C#
143 lines
6.4 KiB
C#
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
|
|
|
/// <summary>
|
|
/// Converts a parsed <see cref="L5kDocument"/> into <see cref="AbCipTagDefinition"/> entries
|
|
/// ready to be merged into <see cref="AbCipDriverOptions.Tags"/>. UDT definitions become
|
|
/// <see cref="AbCipStructureMember"/> lists keyed by data-type name; tags whose
|
|
/// <see cref="L5kTag.DataType"/> matches a known UDT get those members attached so the
|
|
/// discovery code can fan out the structure.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <strong>Alias tags are skipped</strong> — when <see cref="L5kTag.AliasFor"/> is
|
|
/// non-null the entry is dropped at ingest. Surfacing both the alias + its target
|
|
/// creates duplicate Variables in the OPC UA address space (Kepware's L5K importer
|
|
/// takes the same approach for this reason; the alias target is the single source of
|
|
/// truth for storage).
|
|
/// </para>
|
|
/// <para>
|
|
/// <strong>Tags with <c>ExternalAccess := None</c> are skipped</strong> — the controller
|
|
/// actively rejects external reads/writes, so emitting them as Variables would just
|
|
/// produce permanent BadCommunicationError. <c>Read Only</c> maps to <c>Writable=false</c>;
|
|
/// <c>Read/Write</c> (or absent) maps to <c>Writable=true</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// Unknown data-type names (not atomic + not a parsed UDT) fall through as
|
|
/// <see cref="AbCipDataType.Structure"/> with no member layout — discovery can still
|
|
/// expose them as black-box variables and the operator can pin them via dotted paths.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class L5kIngest
|
|
{
|
|
/// <summary>Default device host address applied to every imported tag.</summary>
|
|
public string DefaultDeviceHostAddress { get; init; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// Optional prefix prepended to imported tag names — useful when ingesting multiple
|
|
/// L5K exports into one driver instance to avoid name collisions. Default empty.
|
|
/// </summary>
|
|
public string NamePrefix { get; init; } = string.Empty;
|
|
|
|
public L5kIngestResult Ingest(L5kDocument document)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(document);
|
|
if (string.IsNullOrWhiteSpace(DefaultDeviceHostAddress))
|
|
throw new InvalidOperationException(
|
|
$"{nameof(L5kIngest)}.{nameof(DefaultDeviceHostAddress)} must be set before {nameof(Ingest)} is called — every imported tag needs a target device.");
|
|
|
|
// Index UDT definitions by name so we can fan out structure tags inline.
|
|
var udtIndex = new Dictionary<string, IReadOnlyList<AbCipStructureMember>>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var dt in document.DataTypes)
|
|
{
|
|
var members = new List<AbCipStructureMember>(dt.Members.Count);
|
|
foreach (var m in dt.Members)
|
|
{
|
|
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));
|
|
}
|
|
udtIndex[dt.Name] = members;
|
|
}
|
|
|
|
var tags = new List<AbCipTagDefinition>();
|
|
var skippedAliases = 0;
|
|
var skippedNoAccess = 0;
|
|
foreach (var t in document.Tags)
|
|
{
|
|
if (!string.IsNullOrEmpty(t.AliasFor)) { skippedAliases++; continue; }
|
|
if (IsAccessNone(t.ExternalAccess)) { skippedNoAccess++; continue; }
|
|
|
|
var atomic = TryMapAtomic(t.DataType);
|
|
AbCipDataType dataType;
|
|
IReadOnlyList<AbCipStructureMember>? members = null;
|
|
if (atomic is { } a)
|
|
{
|
|
dataType = a;
|
|
}
|
|
else
|
|
{
|
|
dataType = AbCipDataType.Structure;
|
|
if (udtIndex.TryGetValue(t.DataType, out var udtMembers))
|
|
members = udtMembers;
|
|
}
|
|
|
|
var tagPath = t.ProgramScope is { Length: > 0 }
|
|
? $"Program:{t.ProgramScope}.{t.Name}"
|
|
: t.Name;
|
|
var name = string.IsNullOrEmpty(NamePrefix) ? t.Name : $"{NamePrefix}{t.Name}";
|
|
// Make the OPC UA tag name unique when both controller-scope + program-scope tags
|
|
// share the same simple Name.
|
|
if (t.ProgramScope is { Length: > 0 })
|
|
name = string.IsNullOrEmpty(NamePrefix)
|
|
? $"{t.ProgramScope}.{t.Name}"
|
|
: $"{NamePrefix}{t.ProgramScope}.{t.Name}";
|
|
|
|
var writable = !IsReadOnly(t.ExternalAccess);
|
|
|
|
tags.Add(new AbCipTagDefinition(
|
|
Name: name,
|
|
DeviceHostAddress: DefaultDeviceHostAddress,
|
|
TagPath: tagPath,
|
|
DataType: dataType,
|
|
Writable: writable,
|
|
Members: members));
|
|
}
|
|
|
|
return new L5kIngestResult(tags, skippedAliases, skippedNoAccess);
|
|
}
|
|
|
|
private static bool IsReadOnly(string? externalAccess) =>
|
|
externalAccess is not null
|
|
&& externalAccess.Trim().Replace(" ", string.Empty).Equals("ReadOnly", StringComparison.OrdinalIgnoreCase);
|
|
|
|
private static bool IsAccessNone(string? externalAccess) =>
|
|
externalAccess is not null && externalAccess.Trim().Equals("None", StringComparison.OrdinalIgnoreCase);
|
|
|
|
/// <summary>Map a Logix atomic type name. Returns <c>null</c> for UDT/structure references.</summary>
|
|
private static AbCipDataType? TryMapAtomic(string logixType) =>
|
|
logixType?.Trim().ToUpperInvariant() switch
|
|
{
|
|
"BOOL" or "BIT" => AbCipDataType.Bool,
|
|
"SINT" => AbCipDataType.SInt,
|
|
"INT" => AbCipDataType.Int,
|
|
"DINT" => AbCipDataType.DInt,
|
|
"LINT" => AbCipDataType.LInt,
|
|
"USINT" => AbCipDataType.USInt,
|
|
"UINT" => AbCipDataType.UInt,
|
|
"UDINT" => AbCipDataType.UDInt,
|
|
"ULINT" => AbCipDataType.ULInt,
|
|
"REAL" => AbCipDataType.Real,
|
|
"LREAL" => AbCipDataType.LReal,
|
|
"STRING" => AbCipDataType.String,
|
|
"DT" or "DATETIME" => AbCipDataType.Dt,
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
/// <summary>Result of <see cref="L5kIngest.Ingest"/> — produced tags + per-skip-reason counts.</summary>
|
|
public sealed record L5kIngestResult(
|
|
IReadOnlyList<AbCipTagDefinition> Tags,
|
|
int SkippedAliasCount,
|
|
int SkippedNoAccessCount);
|