namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
///
/// Converts a parsed into entries
/// ready to be merged into . UDT definitions become
/// lists keyed by data-type name; tags whose
/// matches a known UDT get those members attached so the
/// discovery code can fan out the structure.
///
///
///
/// Alias tags are skipped — when 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).
///
///
/// Tags with ExternalAccess := None are skipped — the controller
/// actively rejects external reads/writes, so emitting them as Variables would just
/// produce permanent BadCommunicationError. Read Only maps to Writable=false;
/// Read/Write (or absent) maps to Writable=true.
///
///
/// Unknown data-type names (not atomic + not a parsed UDT) fall through as
/// with no member layout — discovery can still
/// expose them as black-box variables and the operator can pin them via dotted paths.
///
///
public sealed class L5kIngest
{
/// Default device host address applied to every imported tag.
public string DefaultDeviceHostAddress { get; init; } = string.Empty;
///
/// Optional prefix prepended to imported tag names — useful when ingesting multiple
/// L5K exports into one driver instance to avoid name collisions. Default empty.
///
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>(StringComparer.OrdinalIgnoreCase);
foreach (var dt in document.DataTypes)
{
var members = new List(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();
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? 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);
/// Map a Logix atomic type name. Returns null for UDT/structure references.
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,
};
}
/// Result of — produced tags + per-skip-reason counts.
public sealed record L5kIngestResult(
IReadOnlyList Tags,
int SkippedAliasCount,
int SkippedNoAccessCount);