286ab3ba41
Adds PD (PID), MG (Message), PLS (Programmable Limit Switch) and BT
(Block Transfer) file types to the PCCC parser. New AbLegacyDataType
enum members (PidElement / MessageElement / PlsElement /
BlockTransferElement) plus per-type sub-element catalogue:
- PD: SP/PV/CV/KP/KI/KD/MAXS/MINS/DB/OUT as Float32; EN/DN/MO/PE/
AUTO/MAN/SP_VAL/SP_LL/SP_HL as Boolean (word-0 status bits).
- MG: RBE/MS/SIZE/LEN as Int32; EN/EW/ER/DN/ST/CO/NR/TO as Boolean.
- PLS: LEN as Int32 (bit table varies per PLC).
- BT: RLEN/DLEN as Int32; EN/ST/DN/ER/CO/EW/TO/NR as Boolean.
Per-family flags on AbLegacyPlcFamilyProfile gate availability:
- PD/MG: SLC500 + PLC-5 (operator + status bits both present).
- PLS/BT: PLC-5 only (chassis-IO block transfer is PLC-5-specific).
- MicroLogix + LogixPccc: rejected — no legacy file-letter form.
Status-bit indices match Rockwell DTAM / 1747-RM001 / 1785-6.5.12:
PD word 0 bits 0-8, MG/BT word 0 bits 8-15. PLC-set status bits
(PE/DN/SP_*; ST/DN/ER/CO/EW/NR/TO) are surfaced as ViewOnly via
IsPlcSetStatusBit, matching the Timer/Counter/Control pattern from
ablegacy-3.
LibplctagLegacyTagRuntime decodes PD non-bit members as Float32 and
MG/BT/PLS non-bit members as Int32; status bits route through GetBit
with the bit-position encoded by the driver via StatusBitIndex.
Tests: parser positive cases per family + negative cases per family,
catalogue + bit-index + read-only-bit assertions.
Closes #248
311 lines
13 KiB
C#
311 lines
13 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)
|
|
{
|
|
/// <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}";
|
|
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");
|
|
|
|
// 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;
|
|
}
|
|
|
|
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement, indirectFile, indirectWord);
|
|
}
|
|
|
|
/// <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,
|
|
};
|
|
}
|
|
}
|