AOI-aware browse paths: AOI instances now fan out under directional sub-folders (Inputs/, Outputs/, InOut/) instead of a flat layout. The sub-folders only appear when at least one member carries a non-Local AoiQualifier, so plain UDT tags keep the pre-2.6 flat structure. - Add AoiQualifier enum (Local / Input / Output / InOut) + new property on AbCipStructureMember (defaults to Local). - L5K parser learns ADD_ON_INSTRUCTION_DEFINITION blocks; PARAMETER entries' Usage attribute flows through L5kMember.Usage. - L5X parser captures the Usage attribute on <Parameter> elements. - L5kIngest maps Usage strings (Input/Output/InOut) to AoiQualifier; null + unknown values map to Local. - AbCipDriver.DiscoverAsync groups directional members under Inputs / Outputs / InOut sub-folders when any member is non-Local. - Tests for L5K AOI block parsing, L5X Usage capture, ingest mapping (both formats), and AOI-vs-plain UDT discovery fan-out. Closes #234
470 lines
19 KiB
C#
470 lines
19 KiB
C#
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;
|
|
}
|
|
|
|
// PR abcip-2.6 — ADD_ON_INSTRUCTION_DEFINITION block. AOI parameters carry a Usage
|
|
// attribute (Input / Output / InOut); each PARAMETER becomes a member of the AOI's
|
|
// L5kDataType entry so AOI-typed tags pick up a layout the same way UDT-typed tags do.
|
|
if (StartsWithKeyword(line, "ADD_ON_INSTRUCTION_DEFINITION"))
|
|
{
|
|
var consumed = ParseAoiDefinitionBlock(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;
|
|
var description = attributes.TryGetValue("Description", out var d) ? Unquote(d) : null;
|
|
// PR abcip-2.6 — Usage attribute on AOI parameters (Input / Output / InOut). Plain UDT
|
|
// members don't carry it; null on a regular DATATYPE MEMBER is the default + maps to Local
|
|
// in the ingest layer.
|
|
var usage = attributes.TryGetValue("Usage", out var u) ? u.Trim() : null;
|
|
return new L5kMember(name, typePart, arrayDim, externalAccess, description, usage);
|
|
}
|
|
|
|
// ---- AOI block ---------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// PR abcip-2.6 — parse <c>ADD_ON_INSTRUCTION_DEFINITION ... END_ADD_ON_INSTRUCTION_DEFINITION</c>
|
|
/// blocks. Body is structured around PARAMETER entries (each carrying a <c>Usage</c>
|
|
/// attribute) and optional LOCAL_TAGS / ROUTINE blocks. We extract the parameters as
|
|
/// <see cref="L5kMember"/> rows + leave routines alone — only the surface API matters for
|
|
/// tag-discovery fan-out. The L5K format encloses parameters either inside a
|
|
/// <c>PARAMETERS ... END_PARAMETERS</c> block or as bare <c>PARAMETER ... ;</c> lines at
|
|
/// the AOI top level depending on Studio 5000 export options; this parser accepts both.
|
|
/// </summary>
|
|
private static int ParseAoiDefinitionBlock(string[] lines, int start, List<L5kDataType> into)
|
|
{
|
|
var first = lines[start].Trim();
|
|
var head = first.Substring("ADD_ON_INSTRUCTION_DEFINITION".Length).Trim();
|
|
var name = ExtractFirstQuotedOrToken(head);
|
|
var members = new List<L5kMember>();
|
|
var i = start + 1;
|
|
var inLocalsBlock = false;
|
|
var inRoutineBlock = false;
|
|
while (i < lines.Length)
|
|
{
|
|
var line = lines[i].Trim();
|
|
if (StartsWithKeyword(line, "END_ADD_ON_INSTRUCTION_DEFINITION"))
|
|
{
|
|
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
|
|
return i - start + 1;
|
|
}
|
|
if (line.Length == 0) { i++; continue; }
|
|
|
|
// Skip routine bodies — they hold ladder / ST / FBD code we don't care about for
|
|
// tag-discovery, and their own END_ROUTINE / END_LOCAL_TAGS tokens close them out.
|
|
if (StartsWithKeyword(line, "ROUTINE")) { inRoutineBlock = true; i++; continue; }
|
|
if (StartsWithKeyword(line, "END_ROUTINE")) { inRoutineBlock = false; i++; continue; }
|
|
if (StartsWithKeyword(line, "LOCAL_TAGS")) { inLocalsBlock = true; i++; continue; }
|
|
if (StartsWithKeyword(line, "END_LOCAL_TAGS")) { inLocalsBlock = false; i++; continue; }
|
|
if (inRoutineBlock || inLocalsBlock) { i++; continue; }
|
|
|
|
// PARAMETERS / END_PARAMETERS wrappers are skipped — bare PARAMETER lines drive parsing.
|
|
if (StartsWithKeyword(line, "PARAMETERS")) { i++; continue; }
|
|
if (StartsWithKeyword(line, "END_PARAMETERS")) { i++; continue; }
|
|
|
|
if (StartsWithKeyword(line, "PARAMETER"))
|
|
{
|
|
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_ADD_ON_INSTRUCTION_DEFINITION")) break;
|
|
i++;
|
|
sb.Append(' ').Append(peek);
|
|
}
|
|
var entry = sb.ToString().TrimEnd(';').Trim();
|
|
entry = entry.Substring("PARAMETER".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;
|
|
}
|
|
|
|
// ---- 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 or AOI parameter list.</summary>
|
|
/// <remarks>
|
|
/// PR abcip-2.6 — <see cref="Usage"/> carries the AOI <c>Usage</c> attribute (<c>Input</c> /
|
|
/// <c>Output</c> / <c>InOut</c>) raw text. Plain UDT members + L5K AOI <c>LOCAL_TAGS</c> leave
|
|
/// it null; the ingest layer maps null → <see cref="AoiQualifier.Local"/>.
|
|
/// </remarks>
|
|
public sealed record L5kMember(
|
|
string Name,
|
|
string DataType,
|
|
int? ArrayDim,
|
|
string? ExternalAccess,
|
|
string? Description = null,
|
|
string? Usage = null);
|