using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; /// /// Parsed PCCC file-based address: file letter + file number + word number, optionally a /// sub-element (.ACC on a timer) or bit index (/0 on a bit file). /// /// /// Logix symbolic tags are parsed elsewhere ( is for SLC / PLC-5 / /// MicroLogix — no symbol table; everything is file-letter + file-number + word-number). /// /// N7:0 — integer file 7, word 0 (signed 16-bit). /// N7:5 — integer file 7, word 5. /// F8:0 — float file 8, word 0 (32-bit IEEE754). /// B3:0/0 — bit file 3, word 0, bit 0. /// ST9:0 — string file 9, string 0 (82-byte fixed-length + length word). /// T4:0.ACC — timer file 4, timer 0, accumulator sub-element. /// C5:0.PRE — counter file 5, counter 0, preset sub-element. /// I:0/0 — input file, slot 0, bit 0 (no file-number for I/O). /// O:1/2 — output file, slot 1, bit 2. /// S:1 — status file, word 1. /// L9:0 — long-integer file (SLC 5/05+, 32-bit). /// /// Pass the original string straight through to libplctag's name=... attribute — /// the PLC-side decoder handles the format. This parser only validates the shape + surfaces /// the structural pieces for driver-side routing (e.g. deciding whether a tag needs /// bit-level read-modify-write). /// public sealed record AbLegacyAddress( string FileLetter, int? FileNumber, int WordNumber, int? BitIndex, string? SubElement, AbLegacyAddress? IndirectFileSource = null, AbLegacyAddress? IndirectWordSource = null, int? ArrayCount = null) { /// /// PR 7 — PCCC frame ceiling. A single SLC/PLC-5 PCCC read can return up to about 240 /// bytes (~120 INT words / 60 DINTs / 60 floats). The parser caps /// at 120 so a misconfigured tag fails fast instead of bouncing off the wire as a fragmented /// multi-frame read. /// public const int MaxArrayCount = 120; /// /// True when either the file number or the word number is sourced from another PCCC /// address evaluated at runtime (PLC-5 / SLC indirect addressing — N7:[N7:0] or /// N[N7:0]:5). libplctag PCCC does not natively decode bracket-form indirection, /// so the runtime layer must resolve the inner address first and rewrite the tag name /// before issuing the actual read/write. See . /// public bool IsIndirect => IndirectFileSource is not null || IndirectWordSource is not null; public string ToLibplctagName() { // Re-emit using bracket form when indirect. libplctag's PCCC text decoder does not // accept the bracket form directly — callers that need a libplctag-ready name must // resolve the inner addresses first and substitute concrete numbers. Driver runtime // path (TODO: resolve-then-read) is gated on IsIndirect. string filePart; if (IndirectFileSource is not null) { filePart = $"{FileLetter}[{IndirectFileSource.ToLibplctagName()}]"; } else { filePart = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}"; } string wordSegment = IndirectWordSource is not null ? $"[{IndirectWordSource.ToLibplctagName()}]" : WordNumber.ToString(); var wordPart = $"{filePart}:{wordSegment}"; // PR 7 — emit libplctag's `[N]` array suffix when the parsed address carries an // ArrayCount. libplctag's PCCC text decoder treats `N7:0[10]` as "10 consecutive // words starting at N7:0"; the comma form (`N7:0,10`) is Rockwell-native and gets // canonicalised to bracket form here so the driver always hands libplctag a single // recognisable shape. if (ArrayCount is int n) wordPart += $"[{n}]"; if (SubElement is not null) wordPart += $".{SubElement}"; if (BitIndex is not null) wordPart += $"/{BitIndex}"; return wordPart; } public static AbLegacyAddress? TryParse(string? value) => TryParse(value, family: null); /// /// Family-aware parser. PLC-5 (RSLogix 5) displays the word + bit indices on /// I:/O: file references as octal — I:001/17 is rack 1, bit 15. /// Pass the device's family so the parser can interpret those digits as octal when the /// family's is true. The parsed /// record stores decimal values; emits decimal too, which /// is what libplctag's PCCC layer expects. /// /// /// Also accepts indirect / indexed forms (Issue #247): N7:[N7:0] reads file 7, /// word=value-of(N7:0); N[N7:0]:5 reads file=value-of(N7:0), word 5. Recursion /// depth is capped at 1 — the inner address must be a plain direct PCCC address. /// public static AbLegacyAddress? TryParse(string? value, AbLegacyPlcFamily? family) { if (string.IsNullOrWhiteSpace(value)) return null; var src = value.Trim(); var profile = family is null ? null : AbLegacyPlcFamilyProfile.ForFamily(family.Value); // BitIndex: trailing /N. Defer numeric parsing until the file letter is known — PLC-5 // I:/O: bit indices are octal in RSLogix 5, everything else is decimal. string? bitText = null; var slashIdx = src.LastIndexOf('/'); if (slashIdx >= 0 && slashIdx > src.LastIndexOf(']')) { bitText = src[(slashIdx + 1)..]; src = src[..slashIdx]; } return ParseTail(src, bitText, profile, allowIndirect: true); } private static AbLegacyAddress? ParseTail(string src, string? bitText, AbLegacyPlcFamilyProfile? profile, bool allowIndirect) { // SubElement: trailing .NAME (ACC / PRE / EN / DN / TT / CU / CD / FD / etc.) // Only consider dots OUTSIDE of any bracketed inner address — the inner address may // itself contain a sub-element dot (e.g. N[T4:0.ACC]:5). string? subElement = null; var dotIdx = LastIndexOfTopLevel(src, '.'); if (dotIdx >= 0) { var candidate = src[(dotIdx + 1)..]; if (candidate.Length > 0 && candidate.All(char.IsLetter)) { subElement = candidate.ToUpperInvariant(); src = src[..dotIdx]; } } var colonIdx = IndexOfTopLevel(src, ':'); if (colonIdx <= 0) return null; var filePart = src[..colonIdx]; var wordPart = src[(colonIdx + 1)..]; // File letter (always literal) + optional file number — either decimal digits or a // bracketed indirect address like N[N7:0]. if (filePart.Length == 0 || !char.IsLetter(filePart[0])) return null; var letterEnd = 1; while (letterEnd < filePart.Length && char.IsLetter(filePart[letterEnd])) letterEnd++; var letter = filePart[..letterEnd].ToUpperInvariant(); int? fileNumber = null; AbLegacyAddress? indirectFile = null; if (letterEnd < filePart.Length) { var fileTail = filePart[letterEnd..]; if (fileTail.Length >= 2 && fileTail[0] == '[' && fileTail[^1] == ']') { if (!allowIndirect) return null; var inner = fileTail[1..^1]; indirectFile = ParseInner(inner, profile); if (indirectFile is null) return null; } else { if (!int.TryParse(fileTail, out var fn) || fn < 0) return null; fileNumber = fn; } } // Reject unknown file letters — these cover SLC/ML/PLC-5 canonical families. // Function-file letters (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI) are MicroLogix-only. // Structure-file letters (PD/MG/PLS/BT) are gated per family — PD/MG are common on // SLC500 + PLC-5; PLS/BT are PLC-5 only. MicroLogix and LogixPccc reject them. if (!IsKnownFileLetter(letter)) { if (IsFunctionFileLetter(letter)) { if (profile?.SupportsFunctionFiles != true) return null; } else if (IsStructureFileLetter(letter)) { if (!StructureFileSupported(letter, profile)) return null; } else return null; } var octalForIo = profile?.OctalIoAddressing == true && (letter == "I" || letter == "O"); // PR 7 — strip an optional array suffix from the trailing edge of the word part. // Two accepted forms: Rockwell-native `,N` (e.g. `N7:0,10`) and libplctag-native // `[N]` (e.g. `N7:0[10]`). Both resolve to the same ArrayCount. The bracket form // collides syntactically with the indirect-word form (`N7:[N7:0]`) — the // disambiguation is "leading bracket = indirect; trailing bracket after the // numeric word literal = array". A trailing `[N]` may also follow an indirect // word (`N7:[N7:0][10]`) — supported. int? arrayCount = null; // Try comma form first — only meaningful when no leading-bracket indirect form is // present. Comma never appears in indirect-word source addresses (those use ':'). var commaIdx = wordPart.LastIndexOf(','); if (commaIdx > 0 && wordPart[0] != '[') { var arrayText = wordPart[(commaIdx + 1)..]; if (!int.TryParse(arrayText, out var ac) || ac < 1 || ac > MaxArrayCount) return null; arrayCount = ac; wordPart = wordPart[..commaIdx]; } else if (wordPart.Length > 0 && wordPart[^1] == ']') { // Trailing `[N]` — only valid when there's already a primary word/indirect // segment in front of it. Walk back to the matching `[`. // Use top-level-aware index so a nested indirect like `[N7:0]` doesn't trip us. // We want the LAST top-level `[` whose body is a pure integer. var openIdx = MatchingOpenBracket(wordPart, wordPart.Length - 1); if (openIdx > 0) { var arrayText = wordPart[(openIdx + 1)..^1]; if (int.TryParse(arrayText, out var ac)) { if (ac < 1 || ac > MaxArrayCount) return null; arrayCount = ac; wordPart = wordPart[..openIdx]; } // If the bracket body isn't a pure integer, leave wordPart alone — likely // an indirect-word source address (handled below) or malformed input. } } // Word part: either a numeric literal (octal-aware for PLC-5 I:/O:) or a bracketed // indirect address. int word = 0; AbLegacyAddress? indirectWord = null; if (wordPart.Length >= 2 && wordPart[0] == '[' && wordPart[^1] == ']') { if (!allowIndirect) return null; var inner = wordPart[1..^1]; indirectWord = ParseInner(inner, profile); if (indirectWord is null) return null; } else { if (!TryParseIndex(wordPart, octalForIo, out word) || word < 0) return null; } int? bitIndex = null; if (bitText is not null) { if (!TryParseIndex(bitText, octalForIo, out var bit) || bit < 0 || bit > 31) return null; bitIndex = bit; } // PR 7 — array tags can't combine with a bit suffix (`N7:0,10/3` is meaningless — // "the third bit of ten different words"?) or with a sub-element pull (`T4:0,5.ACC` // is also meaningless — the sub-element targets one timer's accumulator). The // libplctag PCCC layer would silently accept the combination; reject up-front so // the OPC UA client sees a clean parse failure rather than a wire-level surprise. if (arrayCount is not null) { if (bitIndex is not null) return null; if (subElement is not null) return null; } return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement, indirectFile, indirectWord, arrayCount); } /// /// Find the index of the `[` that matches the `]` at in /// , accounting for nested brackets. Returns -1 if no match. /// private static int MatchingOpenBracket(string s, int closeIdx) { if (closeIdx < 0 || closeIdx >= s.Length || s[closeIdx] != ']') return -1; var depth = 1; for (var i = closeIdx - 1; i >= 0; i--) { if (s[i] == ']') depth++; else if (s[i] == '[') { depth--; if (depth == 0) return i; } } return -1; } /// /// Parse an inner (bracketed) PCCC address with depth-1 cap. The inner address itself /// must NOT be indirect — nesting beyond one level is rejected. /// private static AbLegacyAddress? ParseInner(string inner, AbLegacyPlcFamilyProfile? profile) { if (string.IsNullOrWhiteSpace(inner)) return null; var src = inner.Trim(); // Reject any further bracket — depth cap at 1. if (src.IndexOf('[') >= 0 || src.IndexOf(']') >= 0) return null; string? bitText = null; var slashIdx = src.LastIndexOf('/'); if (slashIdx >= 0) { bitText = src[(slashIdx + 1)..]; src = src[..slashIdx]; } return ParseTail(src, bitText, profile, allowIndirect: false); } private static int IndexOfTopLevel(string s, char c) { var depth = 0; for (var i = 0; i < s.Length; i++) { if (s[i] == '[') depth++; else if (s[i] == ']') depth--; else if (depth == 0 && s[i] == c) return i; } return -1; } private static int LastIndexOfTopLevel(string s, char c) { var depth = 0; var last = -1; for (var i = 0; i < s.Length; i++) { if (s[i] == '[') depth++; else if (s[i] == ']') depth--; else if (depth == 0 && s[i] == c) last = i; } return last; } private static bool TryParseIndex(string text, bool octal, out int value) { if (octal) { // Octal accepts only digits 0-7. Reject 8/9 explicitly. if (text.Length == 0) { value = 0; return false; } var start = 0; var sign = 1; if (text[0] == '-') { sign = -1; start = 1; } if (start >= text.Length) { value = 0; return false; } var acc = 0; for (var i = start; i < text.Length; i++) { var c = text[i]; if (c < '0' || c > '7') { value = 0; return false; } acc = (acc * 8) + (c - '0'); } value = sign * acc; return true; } return int.TryParse(text, out value); } private static bool IsKnownFileLetter(string letter) => letter switch { "N" or "F" or "B" or "L" or "ST" or "T" or "C" or "R" or "I" or "O" or "S" or "A" => true, _ => false, }; /// /// MicroLogix 1100/1400 function-file prefixes. Each maps to a single fixed instance with a /// known sub-element catalogue (see ). /// internal static bool IsFunctionFileLetter(string letter) => letter switch { "RTC" or "HSC" or "DLS" or "MMI" or "PTO" or "PWM" or "STI" or "EII" or "IOS" or "BHI" => true, _ => false, }; /// /// Structure-file prefixes added in #248: PD (PID), MG (Message), PLS (Programmable Limit /// Switch), BT (Block Transfer). Per-family availability is gated by the matching /// Supports*File flag on . /// internal static bool IsStructureFileLetter(string letter) => letter switch { "PD" or "MG" or "PLS" or "BT" => true, _ => false, }; private static bool StructureFileSupported(string letter, AbLegacyPlcFamilyProfile? profile) { if (profile is null) return false; return letter switch { "PD" => profile.SupportsPidFile, "MG" => profile.SupportsMessageFile, "PLS" => profile.SupportsPlsFile, "BT" => profile.SupportsBlockTransferFile, _ => false, }; } }