[abcip] AbCip — L5K parser + ingest #346

Merged
dohertj2 merged 1 commits from auto/abcip/2.1 into auto/driver-gaps 2026-04-25 18:03:33 -04:00
7 changed files with 955 additions and 1 deletions

View File

@@ -1,4 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
@@ -121,7 +122,39 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily);
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
}
foreach (var tag in _options.Tags)
// Pre-declared tags first; L5K imports fill in only the names not already covered
// (operators can override an imported entry by re-declaring it under Tags).
var declaredNames = new HashSet<string>(
_options.Tags.Select(t => t.Name),
StringComparer.OrdinalIgnoreCase);
var allTags = new List<AbCipTagDefinition>(_options.Tags);
foreach (var import in _options.L5kImports)
{
if (string.IsNullOrWhiteSpace(import.DeviceHostAddress))
throw new InvalidOperationException(
"AbCip L5K import is missing DeviceHostAddress — every imported tag needs a target device.");
IL5kSource? src = null;
if (!string.IsNullOrEmpty(import.FilePath))
src = new FileL5kSource(import.FilePath);
else if (!string.IsNullOrEmpty(import.InlineText))
src = new StringL5kSource(import.InlineText);
if (src is null) continue;
var doc = L5kParser.Parse(src);
var ingest = new L5kIngest
{
DefaultDeviceHostAddress = import.DeviceHostAddress,
NamePrefix = import.NamePrefix,
};
var result = ingest.Ingest(doc);
foreach (var importedTag in result.Tags)
{
if (declaredNames.Contains(importedTag.Name)) continue;
allTags.Add(importedTag);
declaredNames.Add(importedTag.Name);
}
}
foreach (var tag in allTags)
{
_tagsByName[tag.Name] = tag;
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })

View File

@@ -21,6 +21,17 @@ public sealed class AbCipDriverOptions
/// <summary>Pre-declared tag map across all devices — AB discovery lands in PR 5.</summary>
public IReadOnlyList<AbCipTagDefinition> Tags { get; init; } = [];
/// <summary>
/// L5K (Studio 5000 controller export) imports merged into <see cref="Tags"/> at
/// <c>InitializeAsync</c>. Each entry points at one L5K file + the device whose tags it
/// describes; the parser extracts <c>TAG</c> + <c>DATATYPE</c> blocks and produces
/// <see cref="AbCipTagDefinition"/> records (alias tags + ExternalAccess=None tags
/// skipped — see <see cref="Import.L5kIngest"/>). Pre-declared <see cref="Tags"/> entries
/// win on <c>Name</c> conflicts so operators can override import results without
/// editing the L5K source.
/// </summary>
public IReadOnlyList<AbCipL5kImportOptions> L5kImports { get; init; } = [];
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
public AbCipProbeOptions Probe { get; init; } = new();
@@ -123,6 +134,22 @@ public sealed record AbCipStructureMember(
bool WriteIdempotent = false,
int? StringLength = null);
/// <summary>
/// One L5K-import entry. Either <see cref="FilePath"/> or <see cref="InlineText"/> must be
/// set (FilePath wins when both supplied — useful for tests that pre-load fixtures into
/// options without touching disk).
/// </summary>
/// <param name="DeviceHostAddress">Target device <c>HostAddress</c> tags from this file are bound to.</param>
/// <param name="FilePath">On-disk path to a <c>*.L5K</c> export. Loaded eagerly at InitializeAsync.</param>
/// <param name="InlineText">Pre-loaded L5K body — used by tests + Admin UI uploads.</param>
/// <param name="NamePrefix">Optional prefix prepended to imported tag names to avoid collisions
/// when ingesting multiple files into one driver instance.</param>
public sealed record AbCipL5kImportOptions(
string DeviceHostAddress,
string? FilePath = null,
string? InlineText = null,
string NamePrefix = "");
/// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary>
public enum AbCipPlcFamily
{

View File

@@ -0,0 +1,29 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
/// <summary>
/// Abstraction over an L5K text source so the parser can consume strings, files, or streams
/// without coupling to <see cref="System.IO"/>. Implementations return the full text in a
/// single call — L5K files are typically &lt;10 MB even for large controllers, and the parser
/// needs random access to handle nested DATATYPE/TAG blocks regardless.
/// </summary>
public interface IL5kSource
{
/// <summary>Reads the full L5K body as a string.</summary>
string ReadAll();
}
/// <summary>String-backed source — used by tests + when the L5K body is loaded elsewhere.</summary>
public sealed class StringL5kSource : IL5kSource
{
private readonly string _text;
public StringL5kSource(string text) => _text = text ?? throw new ArgumentNullException(nameof(text));
public string ReadAll() => _text;
}
/// <summary>File-backed source — used by Admin / driver init to load <c>*.L5K</c> exports.</summary>
public sealed class FileL5kSource : IL5kSource
{
private readonly string _path;
public FileL5kSource(string path) => _path = path ?? throw new ArgumentNullException(nameof(path));
public string ReadAll() => System.IO.File.ReadAllText(_path);
}

View File

@@ -0,0 +1,142 @@
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);

View File

@@ -0,0 +1,380 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
/// <summary>
/// Pure-text parser for Studio 5000 L5K controller exports. L5K is a labelled-section export
/// with TAG/END_TAG, DATATYPE/END_DATATYPE, PROGRAM/END_PROGRAM blocks. This parser handles
/// the common shapes:
/// <list type="bullet">
/// <item>Controller-scope <c>TAG ... END_TAG</c> with <c>Name</c>, <c>DataType</c>,
/// optional <c>ExternalAccess</c>, optional <c>Description</c>.</item>
/// <item>Program-scope tags inside <c>PROGRAM ... END_PROGRAM</c>.</item>
/// <item>UDT definitions via <c>DATATYPE ... END_DATATYPE</c> with <c>MEMBER</c> lines.</item>
/// <item>Alias tags (<c>AliasFor</c>) — recognised + flagged so callers can skip them.</item>
/// </list>
/// Unknown sections (CONFIG, MODULE, AOI, MOTION_GROUP, etc.) are skipped silently.
/// Per Kepware precedent, alias tags are typically skipped on ingest because the alias target
/// is what owns the storage — surfacing both creates duplicate writes/reads.
/// </summary>
/// <remarks>
/// This is a permissive line-oriented parser, not a full L5K grammar. Comments
/// (<c>(* ... *)</c>) are stripped before tokenization. The parser is deliberately tolerant of
/// extra whitespace, unknown attributes, and trailing semicolons — real-world L5K files are
/// produced by RSLogix exports that vary across versions.
/// </remarks>
public static class L5kParser
{
public static L5kDocument Parse(IL5kSource source)
{
ArgumentNullException.ThrowIfNull(source);
var raw = source.ReadAll();
var stripped = StripBlockComments(raw);
var lines = stripped.Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.None);
var tags = new List<L5kTag>();
var datatypes = new List<L5kDataType>();
string? currentProgram = null;
var i = 0;
while (i < lines.Length)
{
var line = lines[i].Trim();
if (line.Length == 0) { i++; continue; }
// PROGRAM block — opens a program scope; the body contains nested TAG blocks.
if (StartsWithKeyword(line, "PROGRAM"))
{
currentProgram = ExtractFirstQuotedOrToken(line.Substring("PROGRAM".Length).Trim());
i++;
continue;
}
if (StartsWithKeyword(line, "END_PROGRAM"))
{
currentProgram = null;
i++;
continue;
}
// TAG block — collects 1..N tag entries until END_TAG.
if (StartsWithKeyword(line, "TAG"))
{
var consumed = ParseTagBlock(lines, i, currentProgram, tags);
i += consumed;
continue;
}
// DATATYPE block.
if (StartsWithKeyword(line, "DATATYPE"))
{
var consumed = ParseDataTypeBlock(lines, i, datatypes);
i += consumed;
continue;
}
i++;
}
return new L5kDocument(tags, datatypes);
}
// ---- TAG block ---------------------------------------------------------
// Each TAG block contains 1..N entries of the form:
// TagName : DataType (Description := "...", ExternalAccess := Read/Write) := initialValue;
// until END_TAG. Entries can span multiple lines, terminated by ';'.
private static int ParseTagBlock(string[] lines, int start, string? program, List<L5kTag> into)
{
var i = start + 1;
while (i < lines.Length)
{
var line = lines[i].Trim();
if (StartsWithKeyword(line, "END_TAG")) return i - start + 1;
if (line.Length == 0) { i++; continue; }
var sb = new System.Text.StringBuilder(line);
while (!sb.ToString().TrimEnd().EndsWith(';') && i + 1 < lines.Length)
{
var peek = lines[i + 1].Trim();
if (StartsWithKeyword(peek, "END_TAG")) break;
i++;
sb.Append(' ').Append(peek);
}
i++;
var entry = sb.ToString().TrimEnd(';').Trim();
var tag = ParseTagEntry(entry, program);
if (tag is not null) into.Add(tag);
}
return i - start;
}
private static L5kTag? ParseTagEntry(string entry, string? program)
{
// entry shape: Name : DataType [ (attribute := value, ...) ] [ := initialValue ]
// Find the first ':' that separates Name from DataType. Avoid ':=' (the assign op).
var colonIdx = FindBareColon(entry);
if (colonIdx < 0) return null;
var name = entry.Substring(0, colonIdx).Trim();
if (name.Length == 0) return null;
var rest = entry.Substring(colonIdx + 1).Trim();
// The attribute parens themselves contain ':=' assignments, so locate the top-level
// assignment (depth-0 ':=') that introduces the initial value before stripping.
var assignIdx = FindTopLevelAssign(rest);
var head = assignIdx >= 0 ? rest.Substring(0, assignIdx).Trim() : rest;
// Pull attribute tuple out of head: "DataType (attr := val, attr := val)".
string dataType;
var attributes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var openParen = head.IndexOf('(');
if (openParen >= 0)
{
dataType = head.Substring(0, openParen).Trim();
var closeParen = head.LastIndexOf(')');
if (closeParen > openParen)
{
var attrBody = head.Substring(openParen + 1, closeParen - openParen - 1);
ParseAttributeList(attrBody, attributes);
}
}
else
{
dataType = head.Trim();
}
if (dataType.Length == 0) return null;
var description = attributes.TryGetValue("Description", out var d) ? Unquote(d) : null;
var externalAccess = attributes.TryGetValue("ExternalAccess", out var ea) ? ea.Trim() : null;
var aliasFor = attributes.TryGetValue("AliasFor", out var af) ? Unquote(af) : null;
return new L5kTag(
Name: name,
DataType: dataType,
ProgramScope: program,
ExternalAccess: externalAccess,
Description: description,
AliasFor: aliasFor);
}
// Find the first ':=' at depth 0 (not inside parens / brackets / quotes). Returns -1 if none.
private static int FindTopLevelAssign(string entry)
{
var depth = 0;
var inQuote = false;
for (var k = 0; k < entry.Length - 1; k++)
{
var c = entry[k];
if (c == '"' || c == '\'') inQuote = !inQuote;
if (inQuote) continue;
if (c == '(' || c == '[' || c == '{') depth++;
else if (c == ')' || c == ']' || c == '}') depth--;
else if (c == ':' && entry[k + 1] == '=' && depth == 0) return k;
}
return -1;
}
// Find the first colon that is NOT part of ':=' and not inside a quoted string.
private static int FindBareColon(string entry)
{
var inQuote = false;
for (var k = 0; k < entry.Length; k++)
{
var c = entry[k];
if (c == '"' || c == '\'') inQuote = !inQuote;
if (inQuote) continue;
if (c != ':') continue;
if (k + 1 < entry.Length && entry[k + 1] == '=') continue;
return k;
}
return -1;
}
private static void ParseAttributeList(string body, Dictionary<string, string> into)
{
foreach (var part in SplitTopLevelCommas(body))
{
var assign = part.IndexOf(":=", StringComparison.Ordinal);
if (assign < 0) continue;
var key = part.Substring(0, assign).Trim();
var val = part.Substring(assign + 2).Trim();
if (key.Length > 0) into[key] = val;
}
}
private static IEnumerable<string> SplitTopLevelCommas(string body)
{
var depth = 0;
var inQuote = false;
var start = 0;
for (var k = 0; k < body.Length; k++)
{
var c = body[k];
if (c == '"' || c == '\'') inQuote = !inQuote;
if (inQuote) continue;
if (c == '(' || c == '[' || c == '{') depth++;
else if (c == ')' || c == ']' || c == '}') depth--;
else if (c == ',' && depth == 0)
{
yield return body.Substring(start, k - start);
start = k + 1;
}
}
if (start < body.Length) yield return body.Substring(start);
}
// ---- DATATYPE block ----------------------------------------------------
private static int ParseDataTypeBlock(string[] lines, int start, List<L5kDataType> into)
{
var first = lines[start].Trim();
var head = first.Substring("DATATYPE".Length).Trim();
var name = ExtractFirstQuotedOrToken(head);
var members = new List<L5kMember>();
var i = start + 1;
while (i < lines.Length)
{
var line = lines[i].Trim();
if (StartsWithKeyword(line, "END_DATATYPE"))
{
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
return i - start + 1;
}
if (line.Length == 0) { i++; continue; }
if (StartsWithKeyword(line, "MEMBER"))
{
var sb = new System.Text.StringBuilder(line);
while (!sb.ToString().TrimEnd().EndsWith(';') && i + 1 < lines.Length)
{
var peek = lines[i + 1].Trim();
if (StartsWithKeyword(peek, "END_DATATYPE")) break;
i++;
sb.Append(' ').Append(peek);
}
var entry = sb.ToString().TrimEnd(';').Trim();
entry = entry.Substring("MEMBER".Length).Trim();
var member = ParseMemberEntry(entry);
if (member is not null) members.Add(member);
}
i++;
}
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
return i - start;
}
private static L5kMember? ParseMemberEntry(string entry)
{
// entry shape: MemberName : DataType [ [arrayDim] ] [ (attr := val, ...) ] [ := default ]
var colonIdx = FindBareColon(entry);
if (colonIdx < 0) return null;
var name = entry.Substring(0, colonIdx).Trim();
if (name.Length == 0) return null;
var rest = entry.Substring(colonIdx + 1).Trim();
var assignIdx = FindTopLevelAssign(rest);
if (assignIdx >= 0) rest = rest.Substring(0, assignIdx).Trim();
int? arrayDim = null;
var bracketOpen = rest.IndexOf('[');
if (bracketOpen >= 0)
{
var bracketClose = rest.IndexOf(']', bracketOpen + 1);
if (bracketClose > bracketOpen)
{
var dimText = rest.Substring(bracketOpen + 1, bracketClose - bracketOpen - 1).Trim();
if (int.TryParse(dimText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dim))
arrayDim = dim;
rest = (rest.Substring(0, bracketOpen) + rest.Substring(bracketClose + 1)).Trim();
}
}
string typePart;
var attributes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var openParen = rest.IndexOf('(');
if (openParen >= 0)
{
typePart = rest.Substring(0, openParen).Trim();
var closeParen = rest.LastIndexOf(')');
if (closeParen > openParen)
{
var attrBody = rest.Substring(openParen + 1, closeParen - openParen - 1);
ParseAttributeList(attrBody, attributes);
}
}
else
{
typePart = rest.Trim();
}
if (typePart.Length == 0) return null;
var externalAccess = attributes.TryGetValue("ExternalAccess", out var ea) ? ea.Trim() : null;
return new L5kMember(name, typePart, arrayDim, externalAccess);
}
// ---- helpers -----------------------------------------------------------
private static bool StartsWithKeyword(string line, string keyword)
{
if (line.Length < keyword.Length) return false;
if (!line.StartsWith(keyword, StringComparison.OrdinalIgnoreCase)) return false;
if (line.Length == keyword.Length) return true;
var next = line[keyword.Length];
return !char.IsLetterOrDigit(next) && next != '_';
}
private static string ExtractFirstQuotedOrToken(string fragment)
{
var trimmed = fragment.TrimStart();
if (trimmed.Length == 0) return string.Empty;
if (trimmed[0] == '"' || trimmed[0] == '\'')
{
var quote = trimmed[0];
var end = trimmed.IndexOf(quote, 1);
if (end > 0) return trimmed.Substring(1, end - 1);
}
var k = 0;
while (k < trimmed.Length)
{
var c = trimmed[k];
if (char.IsWhiteSpace(c) || c == '(' || c == ',' || c == ';') break;
k++;
}
return trimmed.Substring(0, k);
}
private static string Unquote(string s)
{
s = s.Trim();
if (s.Length >= 2 && (s[0] == '"' || s[0] == '\'') && s[s.Length - 1] == s[0])
return s.Substring(1, s.Length - 2);
return s;
}
private static string StripBlockComments(string text)
{
// L5K comments: `(* ... *)`. Strip so the line scanner doesn't trip on tokens inside.
var pattern = new Regex(@"\(\*.*?\*\)", RegexOptions.Singleline);
return pattern.Replace(text, string.Empty);
}
}
/// <summary>Output of <see cref="L5kParser.Parse(IL5kSource)"/>.</summary>
public sealed record L5kDocument(IReadOnlyList<L5kTag> Tags, IReadOnlyList<L5kDataType> DataTypes);
/// <summary>One L5K tag entry (controller- or program-scope).</summary>
public sealed record L5kTag(
string Name,
string DataType,
string? ProgramScope,
string? ExternalAccess,
string? Description,
string? AliasFor);
/// <summary>One UDT definition extracted from a <c>DATATYPE ... END_DATATYPE</c> block.</summary>
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);

View File

@@ -0,0 +1,175 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class L5kIngestTests
{
private const string DeviceHost = "ab://10.10.10.1/0,1";
[Fact]
public void Atomic_controller_scope_tag_becomes_AbCipTagDefinition()
{
const string body = """
TAG
Motor1_Speed : DINT (ExternalAccess := Read/Write) := 0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var ingest = new L5kIngest { DefaultDeviceHostAddress = DeviceHost };
var result = ingest.Ingest(doc);
result.Tags.Count.ShouldBe(1);
var tag = result.Tags[0];
tag.Name.ShouldBe("Motor1_Speed");
tag.DeviceHostAddress.ShouldBe(DeviceHost);
tag.TagPath.ShouldBe("Motor1_Speed");
tag.DataType.ShouldBe(AbCipDataType.DInt);
tag.Writable.ShouldBeTrue();
tag.Members.ShouldBeNull();
}
[Fact]
public void Program_scope_tag_uses_Program_prefix_and_compound_name()
{
const string body = """
PROGRAM MainProgram
TAG
StepIndex : DINT := 0;
END_TAG
END_PROGRAM
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
result.Tags.Count.ShouldBe(1);
result.Tags[0].Name.ShouldBe("MainProgram.StepIndex");
result.Tags[0].TagPath.ShouldBe("Program:MainProgram.StepIndex");
}
[Fact]
public void Alias_tag_is_skipped()
{
const string body = """
TAG
Real : DINT := 0;
Aliased : DINT (AliasFor := "Real");
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
result.SkippedAliasCount.ShouldBe(1);
result.Tags.Count.ShouldBe(1);
result.Tags.ShouldAllBe(t => t.Name != "Aliased");
}
[Fact]
public void ExternalAccess_None_tag_is_skipped()
{
const string body = """
TAG
Hidden : DINT (ExternalAccess := None) := 0;
Visible : DINT := 0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
result.SkippedNoAccessCount.ShouldBe(1);
result.Tags.Single().Name.ShouldBe("Visible");
}
[Fact]
public void ExternalAccess_ReadOnly_tag_becomes_non_writable()
{
const string body = """
TAG
Sensor : REAL (ExternalAccess := Read Only) := 0.0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
result.Tags.Single().Writable.ShouldBeFalse();
}
[Fact]
public void UDT_typed_tag_picks_up_member_layout_from_DATATYPE_block()
{
const string body = """
DATATYPE TankUDT
MEMBER Level : REAL := 0.0;
MEMBER Active : BOOL := 0;
END_DATATYPE
TAG
Tank1 : TankUDT := [0.0, 0];
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
var tag = result.Tags.Single();
tag.Name.ShouldBe("Tank1");
tag.DataType.ShouldBe(AbCipDataType.Structure);
tag.Members.ShouldNotBeNull();
tag.Members!.Count.ShouldBe(2);
tag.Members[0].Name.ShouldBe("Level");
tag.Members[0].DataType.ShouldBe(AbCipDataType.Real);
tag.Members[1].Name.ShouldBe("Active");
tag.Members[1].DataType.ShouldBe(AbCipDataType.Bool);
}
[Fact]
public void Unknown_datatype_falls_through_as_structure_with_no_members()
{
const string body = """
TAG
Mystery : SomeUnknownType := 0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
var tag = result.Tags.Single();
tag.DataType.ShouldBe(AbCipDataType.Structure);
tag.Members.ShouldBeNull();
}
[Fact]
public void Ingest_throws_when_DefaultDeviceHostAddress_missing()
{
var doc = new L5kDocument(new[] { new L5kTag("X", "DINT", null, null, null, null) }, Array.Empty<L5kDataType>());
Should.Throw<InvalidOperationException>(() => new L5kIngest().Ingest(doc));
}
[Fact]
public void NamePrefix_is_applied_to_imported_tags()
{
const string body = """
TAG
Speed : DINT := 0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var result = new L5kIngest
{
DefaultDeviceHostAddress = DeviceHost,
NamePrefix = "PLC1_",
}.Ingest(doc);
result.Tags.Single().Name.ShouldBe("PLC1_Speed");
result.Tags.Single().TagPath.ShouldBe("Speed"); // path on the PLC stays unchanged
}
}

View File

@@ -0,0 +1,168 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class L5kParserTests
{
[Fact]
public void Controller_scope_TAG_block_parses_name_datatype_externalaccess()
{
const string body = """
TAG
Motor1_Speed : DINT (Description := "Motor 1 set point", ExternalAccess := Read/Write) := 0;
Tank_Level : REAL (ExternalAccess := Read Only) := 0.0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
doc.Tags.Count.ShouldBe(2);
doc.Tags[0].Name.ShouldBe("Motor1_Speed");
doc.Tags[0].DataType.ShouldBe("DINT");
doc.Tags[0].ProgramScope.ShouldBeNull();
doc.Tags[0].ExternalAccess.ShouldBe("Read/Write");
doc.Tags[0].Description.ShouldBe("Motor 1 set point");
doc.Tags[0].AliasFor.ShouldBeNull();
doc.Tags[1].Name.ShouldBe("Tank_Level");
doc.Tags[1].DataType.ShouldBe("REAL");
doc.Tags[1].ExternalAccess.ShouldBe("Read Only");
}
[Fact]
public void Program_scope_TAG_block_carries_program_name()
{
const string body = """
PROGRAM MainProgram (Class := Standard)
TAG
StepIndex : DINT := 0;
Running : BOOL := 0;
END_TAG
END_PROGRAM
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
doc.Tags.Count.ShouldBe(2);
doc.Tags.ShouldAllBe(t => t.ProgramScope == "MainProgram");
doc.Tags.Select(t => t.Name).ShouldBe(["StepIndex", "Running"]);
}
[Fact]
public void Alias_tag_is_flagged()
{
const string body = """
TAG
Motor1 : DINT := 0;
Motor1_Alias : DINT (AliasFor := "Motor1", ExternalAccess := Read/Write);
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var alias = doc.Tags.Single(t => t.Name == "Motor1_Alias");
alias.AliasFor.ShouldBe("Motor1");
}
[Fact]
public void DATATYPE_block_collects_member_lines()
{
const string body = """
DATATYPE TankUDT (FamilyType := NoFamily)
MEMBER Level : REAL (ExternalAccess := Read/Write) := 0.0;
MEMBER Pressure : REAL := 0.0;
MEMBER Active : BOOL := 0;
END_DATATYPE
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
doc.DataTypes.Count.ShouldBe(1);
var udt = doc.DataTypes[0];
udt.Name.ShouldBe("TankUDT");
udt.Members.Count.ShouldBe(3);
udt.Members[0].Name.ShouldBe("Level");
udt.Members[0].DataType.ShouldBe("REAL");
udt.Members[0].ExternalAccess.ShouldBe("Read/Write");
udt.Members[1].Name.ShouldBe("Pressure");
udt.Members[2].Name.ShouldBe("Active");
udt.Members[2].DataType.ShouldBe("BOOL");
}
[Fact]
public void DATATYPE_member_with_array_dim_keeps_type_clean()
{
const string body = """
DATATYPE BatchUDT
MEMBER Recipe : DINT[16] := 0;
MEMBER Name : STRING := "";
END_DATATYPE
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
var udt = doc.DataTypes[0];
var recipe = udt.Members.First(m => m.Name == "Recipe");
recipe.DataType.ShouldBe("DINT");
recipe.ArrayDim.ShouldBe(16);
var nameMember = udt.Members.First(m => m.Name == "Name");
nameMember.DataType.ShouldBe("STRING");
nameMember.ArrayDim.ShouldBeNull();
}
[Fact]
public void Block_comments_are_stripped_before_parsing()
{
const string body = """
(* This is a long
multi-line comment with TAG and END_TAG inside, parser must skip *)
TAG
Real_Tag : DINT := 0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
doc.Tags.Count.ShouldBe(1);
doc.Tags[0].Name.ShouldBe("Real_Tag");
}
[Fact]
public void Unknown_sections_are_skipped_silently()
{
const string body = """
CONFIG SomeConfig (Class := Standard)
ConfigData := 0;
END_CONFIG
MOTION_GROUP Motion1
Member := whatever;
END_MOTION_GROUP
TAG
Real_Tag : DINT := 0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
doc.Tags.Count.ShouldBe(1);
doc.Tags[0].Name.ShouldBe("Real_Tag");
}
[Fact]
public void Multi_line_TAG_entry_is_concatenated()
{
const string body = """
TAG
Motor1 : DINT (Description := "Long description spanning",
ExternalAccess := Read/Write) := 0;
END_TAG
""";
var doc = L5kParser.Parse(new StringL5kSource(body));
doc.Tags.Count.ShouldBe(1);
doc.Tags[0].Description.ShouldBe("Long description spanning");
doc.Tags[0].ExternalAccess.ShouldBe("Read/Write");
}
}