diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index ee494b5..133420c 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -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( + _options.Tags.Select(t => t.Name), + StringComparer.OrdinalIgnoreCase); + var allTags = new List(_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 }) diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs index 8c2c8d7..1cb5625 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverOptions.cs @@ -21,6 +21,17 @@ public sealed class AbCipDriverOptions /// Pre-declared tag map across all devices — AB discovery lands in PR 5. public IReadOnlyList Tags { get; init; } = []; + /// + /// L5K (Studio 5000 controller export) imports merged into at + /// InitializeAsync. Each entry points at one L5K file + the device whose tags it + /// describes; the parser extracts TAG + DATATYPE blocks and produces + /// records (alias tags + ExternalAccess=None tags + /// skipped — see ). Pre-declared entries + /// win on Name conflicts so operators can override import results without + /// editing the L5K source. + /// + public IReadOnlyList L5kImports { get; init; } = []; + /// Per-device probe settings. Falls back to defaults when omitted. public AbCipProbeOptions Probe { get; init; } = new(); @@ -123,6 +134,22 @@ public sealed record AbCipStructureMember( bool WriteIdempotent = false, int? StringLength = null); +/// +/// One L5K-import entry. Either or must be +/// set (FilePath wins when both supplied — useful for tests that pre-load fixtures into +/// options without touching disk). +/// +/// Target device HostAddress tags from this file are bound to. +/// On-disk path to a *.L5K export. Loaded eagerly at InitializeAsync. +/// Pre-loaded L5K body — used by tests + Admin UI uploads. +/// Optional prefix prepended to imported tag names to avoid collisions +/// when ingesting multiple files into one driver instance. +public sealed record AbCipL5kImportOptions( + string DeviceHostAddress, + string? FilePath = null, + string? InlineText = null, + string NamePrefix = ""); + /// Which AB PLC family the device is — selects the profile applied to connection params. public enum AbCipPlcFamily { diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/IL5kSource.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/IL5kSource.cs new file mode 100644 index 0000000..7ee1deb --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/IL5kSource.cs @@ -0,0 +1,29 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import; + +/// +/// Abstraction over an L5K text source so the parser can consume strings, files, or streams +/// without coupling to . 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. +/// +public interface IL5kSource +{ + /// Reads the full L5K body as a string. + string ReadAll(); +} + +/// String-backed source — used by tests + when the L5K body is loaded elsewhere. +public sealed class StringL5kSource : IL5kSource +{ + private readonly string _text; + public StringL5kSource(string text) => _text = text ?? throw new ArgumentNullException(nameof(text)); + public string ReadAll() => _text; +} + +/// File-backed source — used by Admin / driver init to load *.L5K exports. +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); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kIngest.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kIngest.cs new file mode 100644 index 0000000..e9e2ee7 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kIngest.cs @@ -0,0 +1,142 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import; + +/// +/// Converts a parsed into entries +/// ready to be merged into . UDT definitions become +/// lists keyed by data-type name; tags whose +/// matches a known UDT get those members attached so the +/// discovery code can fan out the structure. +/// +/// +/// +/// Alias tags are skipped — when is +/// non-null the entry is dropped at ingest. Surfacing both the alias + its target +/// creates duplicate Variables in the OPC UA address space (Kepware's L5K importer +/// takes the same approach for this reason; the alias target is the single source of +/// truth for storage). +/// +/// +/// Tags with ExternalAccess := None are skipped — the controller +/// actively rejects external reads/writes, so emitting them as Variables would just +/// produce permanent BadCommunicationError. Read Only maps to Writable=false; +/// Read/Write (or absent) maps to Writable=true. +/// +/// +/// Unknown data-type names (not atomic + not a parsed UDT) fall through as +/// with no member layout — discovery can still +/// expose them as black-box variables and the operator can pin them via dotted paths. +/// +/// +public sealed class L5kIngest +{ + /// Default device host address applied to every imported tag. + public string DefaultDeviceHostAddress { get; init; } = string.Empty; + + /// + /// Optional prefix prepended to imported tag names — useful when ingesting multiple + /// L5K exports into one driver instance to avoid name collisions. Default empty. + /// + public string NamePrefix { get; init; } = string.Empty; + + public L5kIngestResult Ingest(L5kDocument document) + { + ArgumentNullException.ThrowIfNull(document); + if (string.IsNullOrWhiteSpace(DefaultDeviceHostAddress)) + throw new InvalidOperationException( + $"{nameof(L5kIngest)}.{nameof(DefaultDeviceHostAddress)} must be set before {nameof(Ingest)} is called — every imported tag needs a target device."); + + // Index UDT definitions by name so we can fan out structure tags inline. + var udtIndex = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var dt in document.DataTypes) + { + var members = new List(dt.Members.Count); + foreach (var m in dt.Members) + { + var atomic = TryMapAtomic(m.DataType); + var memberType = atomic ?? AbCipDataType.Structure; + var writable = !IsReadOnly(m.ExternalAccess) && !IsAccessNone(m.ExternalAccess); + members.Add(new AbCipStructureMember(m.Name, memberType, writable)); + } + udtIndex[dt.Name] = members; + } + + var tags = new List(); + var skippedAliases = 0; + var skippedNoAccess = 0; + foreach (var t in document.Tags) + { + if (!string.IsNullOrEmpty(t.AliasFor)) { skippedAliases++; continue; } + if (IsAccessNone(t.ExternalAccess)) { skippedNoAccess++; continue; } + + var atomic = TryMapAtomic(t.DataType); + AbCipDataType dataType; + IReadOnlyList? members = null; + if (atomic is { } a) + { + dataType = a; + } + else + { + dataType = AbCipDataType.Structure; + if (udtIndex.TryGetValue(t.DataType, out var udtMembers)) + members = udtMembers; + } + + var tagPath = t.ProgramScope is { Length: > 0 } + ? $"Program:{t.ProgramScope}.{t.Name}" + : t.Name; + var name = string.IsNullOrEmpty(NamePrefix) ? t.Name : $"{NamePrefix}{t.Name}"; + // Make the OPC UA tag name unique when both controller-scope + program-scope tags + // share the same simple Name. + if (t.ProgramScope is { Length: > 0 }) + name = string.IsNullOrEmpty(NamePrefix) + ? $"{t.ProgramScope}.{t.Name}" + : $"{NamePrefix}{t.ProgramScope}.{t.Name}"; + + var writable = !IsReadOnly(t.ExternalAccess); + + tags.Add(new AbCipTagDefinition( + Name: name, + DeviceHostAddress: DefaultDeviceHostAddress, + TagPath: tagPath, + DataType: dataType, + Writable: writable, + Members: members)); + } + + return new L5kIngestResult(tags, skippedAliases, skippedNoAccess); + } + + private static bool IsReadOnly(string? externalAccess) => + externalAccess is not null + && externalAccess.Trim().Replace(" ", string.Empty).Equals("ReadOnly", StringComparison.OrdinalIgnoreCase); + + private static bool IsAccessNone(string? externalAccess) => + externalAccess is not null && externalAccess.Trim().Equals("None", StringComparison.OrdinalIgnoreCase); + + /// Map a Logix atomic type name. Returns null for UDT/structure references. + private static AbCipDataType? TryMapAtomic(string logixType) => + logixType?.Trim().ToUpperInvariant() switch + { + "BOOL" or "BIT" => AbCipDataType.Bool, + "SINT" => AbCipDataType.SInt, + "INT" => AbCipDataType.Int, + "DINT" => AbCipDataType.DInt, + "LINT" => AbCipDataType.LInt, + "USINT" => AbCipDataType.USInt, + "UINT" => AbCipDataType.UInt, + "UDINT" => AbCipDataType.UDInt, + "ULINT" => AbCipDataType.ULInt, + "REAL" => AbCipDataType.Real, + "LREAL" => AbCipDataType.LReal, + "STRING" => AbCipDataType.String, + "DT" or "DATETIME" => AbCipDataType.Dt, + _ => null, + }; +} + +/// Result of — produced tags + per-skip-reason counts. +public sealed record L5kIngestResult( + IReadOnlyList Tags, + int SkippedAliasCount, + int SkippedNoAccessCount); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kParser.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kParser.cs new file mode 100644 index 0000000..3216542 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kParser.cs @@ -0,0 +1,380 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import; + +/// +/// 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: +/// +/// Controller-scope TAG ... END_TAG with Name, DataType, +/// optional ExternalAccess, optional Description. +/// Program-scope tags inside PROGRAM ... END_PROGRAM. +/// UDT definitions via DATATYPE ... END_DATATYPE with MEMBER lines. +/// Alias tags (AliasFor) — recognised + flagged so callers can skip them. +/// +/// 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. +/// +/// +/// This is a permissive line-oriented parser, not a full L5K grammar. Comments +/// ((* ... *)) 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. +/// +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(); + var datatypes = new List(); + 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 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(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 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 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 into) + { + var first = lines[start].Trim(); + var head = first.Substring("DATATYPE".Length).Trim(); + var name = ExtractFirstQuotedOrToken(head); + var members = new List(); + 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(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); + } +} + +/// Output of . +public sealed record L5kDocument(IReadOnlyList Tags, IReadOnlyList DataTypes); + +/// One L5K tag entry (controller- or program-scope). +public sealed record L5kTag( + string Name, + string DataType, + string? ProgramScope, + string? ExternalAccess, + string? Description, + string? AliasFor); + +/// One UDT definition extracted from a DATATYPE ... END_DATATYPE block. +public sealed record L5kDataType(string Name, IReadOnlyList Members); + +/// One member line inside a UDT definition. +public sealed record L5kMember(string Name, string DataType, int? ArrayDim, string? ExternalAccess); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kIngestTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kIngestTests.cs new file mode 100644 index 0000000..1bd949c --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kIngestTests.cs @@ -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()); + + Should.Throw(() => 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 + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kParserTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kParserTests.cs new file mode 100644 index 0000000..e26a414 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kParserTests.cs @@ -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"); + } +}