Auto: ablegacy-1 — PLC-5 octal I/O addressing

Closes #244
This commit is contained in:
Joseph Doherty
2026-04-25 13:25:22 -04:00
parent 651d6c005c
commit 8f7265186d
5 changed files with 146 additions and 15 deletions

View File

@@ -1,3 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
@@ -41,21 +43,38 @@ public sealed record AbLegacyAddress(
return wordPart;
}
public static AbLegacyAddress? TryParse(string? value)
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>
public static AbLegacyAddress? TryParse(string? value, AbLegacyPlcFamily? family)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var src = value.Trim();
// BitIndex: trailing /N
int? bitIndex = null;
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.IndexOf('/');
if (slashIdx >= 0)
{
if (!int.TryParse(src[(slashIdx + 1)..], out var bit) || bit < 0 || bit > 31) return null;
bitIndex = bit;
bitText = src[(slashIdx + 1)..];
src = src[..slashIdx];
}
return ParseTail(src, bitText, profile);
}
private static AbLegacyAddress? ParseTail(string src, string? bitText, AbLegacyPlcFamilyProfile? profile)
{
// SubElement: trailing .NAME (ACC / PRE / EN / DN / TT / CU / CD / FD / etc.)
string? subElement = null;
var dotIdx = src.LastIndexOf('.');
@@ -73,7 +92,6 @@ public sealed record AbLegacyAddress(
if (colonIdx <= 0) return null;
var filePart = src[..colonIdx];
var wordPart = src[(colonIdx + 1)..];
if (!int.TryParse(wordPart, out var word) || word < 0) return null;
// File letter + optional file number (single letter for I/O/S, letter+number otherwise).
if (filePart.Length == 0 || !char.IsLetter(filePart[0])) return null;
@@ -91,9 +109,43 @@ public sealed record AbLegacyAddress(
// Reject unknown file letters — these cover SLC/ML/PLC-5 canonical families.
if (!IsKnownFileLetter(letter)) return null;
var octalForIo = profile?.OctalIoAddressing == true && (letter == "I" || letter == "O");
if (!TryParseIndex(wordPart, octalForIo, out var 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);
}
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,

View File

@@ -140,7 +140,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
continue;
}
var parsed = AbLegacyAddress.TryParse(def.Address);
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
var value = runtime.DecodeValue(def.DataType, parsed?.BitIndex);
results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
@@ -186,7 +186,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
try
{
var parsed = AbLegacyAddress.TryParse(def.Address);
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
// PCCC bit-within-word writes — task #181 pass 2. RMW against a parallel
// parent-word runtime (strip the /N bit suffix). Per-parent-word lock serialises
@@ -413,7 +413,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
{
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
var parsed = AbLegacyAddress.TryParse(def.Address)
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily)
?? throw new InvalidOperationException(
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");

View File

@@ -23,7 +23,7 @@ public sealed record AbLegacyDeviceOptions(
/// <summary>
/// One PCCC-backed OPC UA variable. <paramref name="Address"/> is the canonical PCCC
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse"/>.
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse(string?)"/>.
/// </summary>
public sealed record AbLegacyTagDefinition(
string Name,

View File

@@ -9,7 +9,8 @@ public sealed record AbLegacyPlcFamilyProfile(
string DefaultCipPath,
int MaxTagBytes,
bool SupportsStringFile,
bool SupportsLongFile)
bool SupportsLongFile,
bool OctalIoAddressing)
{
public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch
{
@@ -25,21 +26,24 @@ public sealed record AbLegacyPlcFamilyProfile(
DefaultCipPath: "1,0",
MaxTagBytes: 240, // SLC 5/05 PCCC max packet data
SupportsStringFile: true, // ST file available SLC 5/04+
SupportsLongFile: true); // L file available SLC 5/05+
SupportsLongFile: true, // L file available SLC 5/05+
OctalIoAddressing: false); // SLC500 I:/O: indices are decimal in RSLogix 500
public static readonly AbLegacyPlcFamilyProfile MicroLogix = new(
LibplctagPlcAttribute: "micrologix",
DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path
MaxTagBytes: 232,
SupportsStringFile: true,
SupportsLongFile: false); // ML 1100/1200/1400 don't ship L files
SupportsLongFile: false, // ML 1100/1200/1400 don't ship L files
OctalIoAddressing: false); // MicroLogix follows SLC-style decimal I/O addressing
public static readonly AbLegacyPlcFamilyProfile Plc5 = new(
LibplctagPlcAttribute: "plc5",
DefaultCipPath: "1,0",
MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower
SupportsStringFile: true,
SupportsLongFile: false); // PLC-5 predates L files
SupportsLongFile: false, // PLC-5 predates L files
OctalIoAddressing: true); // RSLogix 5 displays I:/O: word + bit indices as octal
/// <summary>
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
@@ -51,7 +55,8 @@ public sealed record AbLegacyPlcFamilyProfile(
DefaultCipPath: "1,0",
MaxTagBytes: 240,
SupportsStringFile: true,
SupportsLongFile: true);
SupportsLongFile: true,
OctalIoAddressing: false); // Logix natively uses decimal arrays even via the PCCC bridge
}
/// <summary>Which PCCC PLC family the device is.</summary>