c689ac58b1
Closes #250
397 lines
17 KiB
C#
397 lines
17 KiB
C#
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
|
|
|
/// <summary>
|
|
/// Parsed PCCC file-based address: file letter + file number + word number, optionally a
|
|
/// sub-element (<c>.ACC</c> on a timer) or bit index (<c>/0</c> on a bit file).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>Logix symbolic tags are parsed elsewhere (<see cref="AbLegacy"/> is for SLC / PLC-5 /
|
|
/// MicroLogix — no symbol table; everything is file-letter + file-number + word-number).</para>
|
|
/// <list type="bullet">
|
|
/// <item><c>N7:0</c> — integer file 7, word 0 (signed 16-bit).</item>
|
|
/// <item><c>N7:5</c> — integer file 7, word 5.</item>
|
|
/// <item><c>F8:0</c> — float file 8, word 0 (32-bit IEEE754).</item>
|
|
/// <item><c>B3:0/0</c> — bit file 3, word 0, bit 0.</item>
|
|
/// <item><c>ST9:0</c> — string file 9, string 0 (82-byte fixed-length + length word).</item>
|
|
/// <item><c>T4:0.ACC</c> — timer file 4, timer 0, accumulator sub-element.</item>
|
|
/// <item><c>C5:0.PRE</c> — counter file 5, counter 0, preset sub-element.</item>
|
|
/// <item><c>I:0/0</c> — input file, slot 0, bit 0 (no file-number for I/O).</item>
|
|
/// <item><c>O:1/2</c> — output file, slot 1, bit 2.</item>
|
|
/// <item><c>S:1</c> — status file, word 1.</item>
|
|
/// <item><c>L9:0</c> — long-integer file (SLC 5/05+, 32-bit).</item>
|
|
/// </list>
|
|
/// <para>Pass the original string straight through to libplctag's <c>name=...</c> 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).</para>
|
|
/// </remarks>
|
|
public sealed record AbLegacyAddress(
|
|
string FileLetter,
|
|
int? FileNumber,
|
|
int WordNumber,
|
|
int? BitIndex,
|
|
string? SubElement,
|
|
AbLegacyAddress? IndirectFileSource = null,
|
|
AbLegacyAddress? IndirectWordSource = null,
|
|
int? ArrayCount = null)
|
|
{
|
|
/// <summary>
|
|
/// 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 <see cref="ArrayCount"/>
|
|
/// at 120 so a misconfigured tag fails fast instead of bouncing off the wire as a fragmented
|
|
/// multi-frame read.
|
|
/// </summary>
|
|
public const int MaxArrayCount = 120;
|
|
|
|
/// <summary>
|
|
/// True when either the file number or the word number is sourced from another PCCC
|
|
/// address evaluated at runtime (PLC-5 / SLC indirect addressing — <c>N7:[N7:0]</c> or
|
|
/// <c>N[N7:0]:5</c>). 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 <see cref="ToLibplctagName"/>.
|
|
/// </summary>
|
|
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);
|
|
|
|
/// <summary>
|
|
/// Family-aware parser. PLC-5 (RSLogix 5) displays the word + bit indices on
|
|
/// <c>I:</c>/<c>O:</c> file references as octal — <c>I:001/17</c> is rack 1, bit 15.
|
|
/// Pass the device's family so the parser can interpret those digits as octal when the
|
|
/// family's <see cref="AbLegacyPlcFamilyProfile.OctalIoAddressing"/> is true. The parsed
|
|
/// record stores decimal values; <see cref="ToLibplctagName"/> emits decimal too, which
|
|
/// is what libplctag's PCCC layer expects.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Also accepts indirect / indexed forms (Issue #247): <c>N7:[N7:0]</c> reads file 7,
|
|
/// word=value-of(N7:0); <c>N[N7:0]:5</c> reads file=value-of(N7:0), word 5. Recursion
|
|
/// depth is capped at 1 — the inner address must be a plain direct PCCC address.
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Find the index of the `[` that matches the `]` at <paramref name="closeIdx"/> in
|
|
/// <paramref name="s"/>, accounting for nested brackets. Returns -1 if no match.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parse an inner (bracketed) PCCC address with depth-1 cap. The inner address itself
|
|
/// must NOT be indirect — nesting beyond one level is rejected.
|
|
/// </summary>
|
|
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,
|
|
};
|
|
|
|
/// <summary>
|
|
/// MicroLogix 1100/1400 function-file prefixes. Each maps to a single fixed instance with a
|
|
/// known sub-element catalogue (see <see cref="AbLegacyDataType"/>).
|
|
/// </summary>
|
|
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,
|
|
};
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <c>Supports*File</c> flag on <see cref="AbLegacyPlcFamilyProfile"/>.
|
|
/// </summary>
|
|
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,
|
|
};
|
|
}
|
|
}
|