[abcip] AbCip — L5K parser + ingest #346
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
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