Auto: abcip-2.1 — L5K parser + ingest
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>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
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);
|
var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily);
|
||||||
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
|
_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;
|
_tagsByName[tag.Name] = tag;
|
||||||
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
|
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ public sealed class AbCipDriverOptions
|
|||||||
/// <summary>Pre-declared tag map across all devices — AB discovery lands in PR 5.</summary>
|
/// <summary>Pre-declared tag map across all devices — AB discovery lands in PR 5.</summary>
|
||||||
public IReadOnlyList<AbCipTagDefinition> Tags { get; init; } = [];
|
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>
|
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
|
||||||
public AbCipProbeOptions Probe { get; init; } = new();
|
public AbCipProbeOptions Probe { get; init; } = new();
|
||||||
|
|
||||||
@@ -123,6 +134,22 @@ public sealed record AbCipStructureMember(
|
|||||||
bool WriteIdempotent = false,
|
bool WriteIdempotent = false,
|
||||||
int? StringLength = null);
|
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>
|
/// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary>
|
||||||
public enum AbCipPlcFamily
|
public enum AbCipPlcFamily
|
||||||
{
|
{
|
||||||
|
|||||||
29
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/IL5kSource.cs
Normal file
29
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/IL5kSource.cs
Normal 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 <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);
|
||||||
|
}
|
||||||
142
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kIngest.cs
Normal file
142
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kIngest.cs
Normal 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);
|
||||||
380
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kParser.cs
Normal file
380
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kParser.cs
Normal 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);
|
||||||
175
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kIngestTests.cs
Normal file
175
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kIngestTests.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
168
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kParserTests.cs
Normal file
168
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kParserTests.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user