Merge pull request '[ablegacy] AbLegacy — PLC-5 octal I/O addressing' (#321) from auto/ablegacy/1 into auto/driver-gaps

This commit was merged in pull request #321.
This commit is contained in:
2026-04-25 13:26:54 -04:00
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; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary> /// <summary>
@@ -41,21 +43,38 @@ public sealed record AbLegacyAddress(
return wordPart; 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; if (string.IsNullOrWhiteSpace(value)) return null;
var src = value.Trim(); var src = value.Trim();
// BitIndex: trailing /N var profile = family is null ? null : AbLegacyPlcFamilyProfile.ForFamily(family.Value);
int? bitIndex = null;
// 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('/'); var slashIdx = src.IndexOf('/');
if (slashIdx >= 0) if (slashIdx >= 0)
{ {
if (!int.TryParse(src[(slashIdx + 1)..], out var bit) || bit < 0 || bit > 31) return null; bitText = src[(slashIdx + 1)..];
bitIndex = bit;
src = src[..slashIdx]; 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.) // SubElement: trailing .NAME (ACC / PRE / EN / DN / TT / CU / CD / FD / etc.)
string? subElement = null; string? subElement = null;
var dotIdx = src.LastIndexOf('.'); var dotIdx = src.LastIndexOf('.');
@@ -73,7 +92,6 @@ public sealed record AbLegacyAddress(
if (colonIdx <= 0) return null; if (colonIdx <= 0) return null;
var filePart = src[..colonIdx]; var filePart = src[..colonIdx];
var wordPart = src[(colonIdx + 1)..]; 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). // File letter + optional file number (single letter for I/O/S, letter+number otherwise).
if (filePart.Length == 0 || !char.IsLetter(filePart[0])) return null; 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. // Reject unknown file letters — these cover SLC/ML/PLC-5 canonical families.
if (!IsKnownFileLetter(letter)) return null; 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); 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 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, "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; continue;
} }
var parsed = AbLegacyAddress.TryParse(def.Address); var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
var value = runtime.DecodeValue(def.DataType, parsed?.BitIndex); var value = runtime.DecodeValue(def.DataType, parsed?.BitIndex);
results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now); results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null); _health = new DriverHealth(DriverState.Healthy, now, null);
@@ -186,7 +186,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
try 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 // 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 // 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; 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( ?? throw new InvalidOperationException(
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'."); $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");

View File

@@ -23,7 +23,7 @@ public sealed record AbLegacyDeviceOptions(
/// <summary> /// <summary>
/// One PCCC-backed OPC UA variable. <paramref name="Address"/> is the canonical PCCC /// 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> /// </summary>
public sealed record AbLegacyTagDefinition( public sealed record AbLegacyTagDefinition(
string Name, string Name,

View File

@@ -9,7 +9,8 @@ public sealed record AbLegacyPlcFamilyProfile(
string DefaultCipPath, string DefaultCipPath,
int MaxTagBytes, int MaxTagBytes,
bool SupportsStringFile, bool SupportsStringFile,
bool SupportsLongFile) bool SupportsLongFile,
bool OctalIoAddressing)
{ {
public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch
{ {
@@ -25,21 +26,24 @@ public sealed record AbLegacyPlcFamilyProfile(
DefaultCipPath: "1,0", DefaultCipPath: "1,0",
MaxTagBytes: 240, // SLC 5/05 PCCC max packet data MaxTagBytes: 240, // SLC 5/05 PCCC max packet data
SupportsStringFile: true, // ST file available SLC 5/04+ 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( public static readonly AbLegacyPlcFamilyProfile MicroLogix = new(
LibplctagPlcAttribute: "micrologix", LibplctagPlcAttribute: "micrologix",
DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path
MaxTagBytes: 232, MaxTagBytes: 232,
SupportsStringFile: true, 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( public static readonly AbLegacyPlcFamilyProfile Plc5 = new(
LibplctagPlcAttribute: "plc5", LibplctagPlcAttribute: "plc5",
DefaultCipPath: "1,0", DefaultCipPath: "1,0",
MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower
SupportsStringFile: true, 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> /// <summary>
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer. /// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
@@ -51,7 +55,8 @@ public sealed record AbLegacyPlcFamilyProfile(
DefaultCipPath: "1,0", DefaultCipPath: "1,0",
MaxTagBytes: 240, MaxTagBytes: 240,
SupportsStringFile: true, 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> /// <summary>Which PCCC PLC family the device is.</summary>

View File

@@ -1,6 +1,7 @@
using Shouldly; using Shouldly;
using Xunit; using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
@@ -65,4 +66,77 @@ public sealed class AbLegacyAddressTests
a.ShouldNotBeNull(); a.ShouldNotBeNull();
a.ToLibplctagName().ShouldBe(input); a.ToLibplctagName().ShouldBe(input);
} }
// ---- PLC-5 octal I:/O: addressing (Issue #244) ----
//
// RSLogix 5 displays I:/O: word + bit indices as octal. `I:001/17` means rack 1, bit 15
// (octal 17). Other PCCC families (SLC500, MicroLogix, LogixPccc) keep decimal indices.
// Non-I/O file letters are always decimal regardless of family.
[Theory]
[InlineData("I:001/17", 1, 15)] // octal 17 → bit 15
[InlineData("I:0/0", 0, 0)] // boundary: octal 0
[InlineData("O:1/2", 1, 2)] // octal 1, 2 happen to match decimal
[InlineData("I:010/10", 8, 8)] // octal 10 → 8 (both word + bit)
[InlineData("I:007/7", 7, 7)] // boundary: largest single octal digit
public void TryParse_Plc5_parses_io_indices_as_octal(string input, int expectedWord, int expectedBit)
{
var a = AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5);
a.ShouldNotBeNull();
a.WordNumber.ShouldBe(expectedWord);
a.BitIndex.ShouldBe(expectedBit);
}
[Theory]
[InlineData("I:8/0")] // word digit 8 illegal in octal
[InlineData("I:0/9")] // bit digit 9 illegal in octal
[InlineData("O:128/0")] // contains digit 8
[InlineData("I:0/18")] // bit field octal-illegal because of '8'
public void TryParse_Plc5_rejects_octal_invalid_io_digits(string input)
{
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5).ShouldBeNull();
}
[Theory]
// Non-I/O files stay decimal even on PLC-5 (e.g. N7:8 is integer 7, word 8).
[InlineData("N7:8", 7, 8)]
[InlineData("F8:9", 8, 9)]
public void TryParse_Plc5_keeps_non_io_indices_decimal(string input, int? expectedFile, int expectedWord)
{
var a = AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5);
a.ShouldNotBeNull();
a.FileNumber.ShouldBe(expectedFile);
a.WordNumber.ShouldBe(expectedWord);
}
[Fact]
public void TryParse_Slc500_keeps_io_indices_decimal_back_compat()
{
// SLC500 has OctalIoAddressing=false — the digits are decimal as before.
var a = AbLegacyAddress.TryParse("I:10/15", AbLegacyPlcFamily.Slc500);
a.ShouldNotBeNull();
a.WordNumber.ShouldBe(10);
a.BitIndex.ShouldBe(15);
// Decimal '8' that PLC-5 would reject is fine on SLC500.
var b = AbLegacyAddress.TryParse("I:8/0", AbLegacyPlcFamily.Slc500);
b.ShouldNotBeNull();
b.WordNumber.ShouldBe(8);
}
[Fact]
public void TryParse_MicroLogix_and_LogixPccc_keep_io_indices_decimal()
{
AbLegacyAddress.TryParse("I:9/0", AbLegacyPlcFamily.MicroLogix).ShouldNotBeNull();
AbLegacyAddress.TryParse("I:9/0", AbLegacyPlcFamily.LogixPccc).ShouldNotBeNull();
}
[Fact]
public void Plc5Profile_advertises_octal_io_addressing()
{
AbLegacyPlcFamilyProfile.Plc5.OctalIoAddressing.ShouldBeTrue();
AbLegacyPlcFamilyProfile.Slc500.OctalIoAddressing.ShouldBeFalse();
AbLegacyPlcFamilyProfile.MicroLogix.OctalIoAddressing.ShouldBeFalse();
AbLegacyPlcFamilyProfile.LogixPccc.OctalIoAddressing.ShouldBeFalse();
}
} }