AOI-aware browse paths: AOI instances now fan out under directional sub-folders (Inputs/, Outputs/, InOut/) instead of a flat layout. The sub-folders only appear when at least one member carries a non-Local AoiQualifier, so plain UDT tags keep the pre-2.6 flat structure. - Add AoiQualifier enum (Local / Input / Output / InOut) + new property on AbCipStructureMember (defaults to Local). - L5K parser learns ADD_ON_INSTRUCTION_DEFINITION blocks; PARAMETER entries' Usage attribute flows through L5kMember.Usage. - L5X parser captures the Usage attribute on <Parameter> elements. - L5kIngest maps Usage strings (Input/Output/InOut) to AoiQualifier; null + unknown values map to Local. - AbCipDriver.DiscoverAsync groups directional members under Inputs / Outputs / InOut sub-folders when any member is non-Local. - Tests for L5K AOI block parsing, L5X Usage capture, ingest mapping (both formats), and AOI-vs-plain UDT discovery fan-out. Closes #234
162 lines
7.2 KiB
C#
162 lines
7.2 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(
|
|
Name: m.Name,
|
|
DataType: memberType,
|
|
Writable: writable,
|
|
Description: m.Description,
|
|
AoiQualifier: MapAoiUsage(m.Usage)));
|
|
}
|
|
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,
|
|
Description: t.Description));
|
|
}
|
|
|
|
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>
|
|
/// PR abcip-2.6 — map the AOI <c>Usage</c> attribute string to <see cref="AoiQualifier"/>.
|
|
/// Plain UDT members (Usage = null) + unrecognised values map to <see cref="AoiQualifier.Local"/>.
|
|
/// </summary>
|
|
private static AoiQualifier MapAoiUsage(string? usage) =>
|
|
usage?.Trim().ToUpperInvariant() switch
|
|
{
|
|
"INPUT" => AoiQualifier.Input,
|
|
"OUTPUT" => AoiQualifier.Output,
|
|
"INOUT" => AoiQualifier.InOut,
|
|
_ => AoiQualifier.Local,
|
|
};
|
|
|
|
/// <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);
|