From 8f7265186d5c79098a37fbb1b71de7c5bb97f8e9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 13:25:22 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20ablegacy-1=20=E2=80=94=20PLC-5=20octal?= =?UTF-8?q?=20I/O=20addressing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #244 --- .../AbLegacyAddress.cs | 64 ++++++++++++++-- .../AbLegacyDriver.cs | 6 +- .../AbLegacyDriverOptions.cs | 2 +- .../PlcFamilies/AbLegacyPlcFamilyProfile.cs | 15 ++-- .../AbLegacyAddressTests.cs | 74 +++++++++++++++++++ 5 files changed, 146 insertions(+), 15 deletions(-) diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs index 3da4f48..f255362 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs @@ -1,3 +1,5 @@ +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; + namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; /// @@ -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); + + /// + /// 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. + /// + 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, diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 3145e58..bef7957 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -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}'."); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs index 6ed3112..0b26c41 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverOptions.cs @@ -23,7 +23,7 @@ public sealed record AbLegacyDeviceOptions( /// /// One PCCC-backed OPC UA variable. is the canonical PCCC -/// file-address string that parses via . +/// file-address string that parses via . /// public sealed record AbLegacyTagDefinition( string Name, diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.cs index 3560570..cb81574 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/PlcFamilies/AbLegacyPlcFamilyProfile.cs @@ -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 /// /// 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 } /// Which PCCC PLC family the device is. diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAddressTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAddressTests.cs index d032357..8039a0f 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAddressTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAddressTests.cs @@ -1,6 +1,7 @@ using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; @@ -65,4 +66,77 @@ public sealed class AbLegacyAddressTests a.ShouldNotBeNull(); 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(); + } } -- 2.49.1