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,
};
}
}