diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs index 54c67f7..1bb89b6 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyAddress.cs @@ -32,12 +32,40 @@ public sealed record AbLegacyAddress( int? FileNumber, int WordNumber, int? BitIndex, - string? SubElement) + string? SubElement, + AbLegacyAddress? IndirectFileSource = null, + AbLegacyAddress? IndirectWordSource = null) { + /// + /// True when either the file number or the word number is sourced from another PCCC + /// address evaluated at runtime (PLC-5 / SLC indirect addressing — N7:[N7:0] or + /// N[N7:0]:5). 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 . + /// + public bool IsIndirect => IndirectFileSource is not null || IndirectWordSource is not null; + public string ToLibplctagName() { - var file = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}"; - var wordPart = $"{file}:{WordNumber}"; + // 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; @@ -53,6 +81,11 @@ public sealed record AbLegacyAddress( /// record stores decimal values; emits decimal too, which /// is what libplctag's PCCC layer expects. /// + /// + /// Also accepts indirect / indexed forms (Issue #247): N7:[N7:0] reads file 7, + /// word=value-of(N7:0); N[N7:0]:5 reads file=value-of(N7:0), word 5. Recursion + /// depth is capped at 1 — the inner address must be a plain direct PCCC address. + /// public static AbLegacyAddress? TryParse(string? value, AbLegacyPlcFamily? family) { if (string.IsNullOrWhiteSpace(value)) return null; @@ -63,21 +96,23 @@ public sealed record AbLegacyAddress( // 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) + var slashIdx = src.LastIndexOf('/'); + if (slashIdx >= 0 && slashIdx > src.LastIndexOf(']')) { bitText = src[(slashIdx + 1)..]; src = src[..slashIdx]; } - return ParseTail(src, bitText, profile); + return ParseTail(src, bitText, profile, allowIndirect: true); } - private static AbLegacyAddress? ParseTail(string src, string? bitText, AbLegacyPlcFamilyProfile? profile) + 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 = src.LastIndexOf('.'); + var dotIdx = LastIndexOfTopLevel(src, '.'); if (dotIdx >= 0) { var candidate = src[(dotIdx + 1)..]; @@ -88,22 +123,35 @@ public sealed record AbLegacyAddress( } } - var colonIdx = src.IndexOf(':'); + var colonIdx = IndexOfTopLevel(src, ':'); if (colonIdx <= 0) return null; var filePart = src[..colonIdx]; var wordPart = src[(colonIdx + 1)..]; - // File letter + optional file number (single letter for I/O/S, letter+number otherwise). + // 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) { - if (!int.TryParse(filePart[letterEnd..], out var fn) || fn < 0) return null; - fileNumber = fn; + 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. @@ -115,7 +163,21 @@ public sealed record AbLegacyAddress( var octalForIo = profile?.OctalIoAddressing == true && (letter == "I" || letter == "O"); - if (!TryParseIndex(wordPart, octalForIo, out var word) || word < 0) return null; + // 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) @@ -124,7 +186,53 @@ public sealed record AbLegacyAddress( bitIndex = bit; } - return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement); + return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement, indirectFile, indirectWord); + } + + /// + /// Parse an inner (bracketed) PCCC address with depth-1 cap. The inner address itself + /// must NOT be indirect — nesting beyond one level is rejected. + /// + 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) diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 86469b4..28a44c3 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -438,6 +438,15 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover ?? throw new InvalidOperationException( $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'."); + // TODO(#247): libplctag's PCCC text decoder does not natively accept the bracket-form + // indirect address. Resolving N7:[N7:0] requires reading the inner address first, then + // rewriting the tag name with the resolved word number, then issuing the actual read. + // For now we surface a clear runtime error rather than letting libplctag fail with an + // opaque parser error. + if (parsed.IsIndirect) + throw new NotSupportedException( + $"AbLegacy tag '{def.Name}' uses indirect addressing ('{def.Address}'); runtime resolution is not yet implemented."); + var runtime = _tagFactory.Create(new AbLegacyTagCreateParams( Gateway: device.ParsedAddress.Gateway, Port: device.ParsedAddress.Port, 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 a3fcc5b..c4f20ea 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAddressTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAddressTests.cs @@ -205,6 +205,121 @@ public sealed class AbLegacyAddressTests AbLegacyPlcFamilyProfile.LogixPccc.SupportsFunctionFiles.ShouldBeFalse(); } + // ---- Indirect / indexed addressing (Issue #247) ---- + // + // PLC-5 / SLC permit `N7:[N7:0]` (word number sourced from another address) and + // `N[N7:0]:5` (file number sourced from another address). Recursion is capped at 1 — the + // inner address must itself be a plain direct PCCC reference. + + [Fact] + public void TryParse_accepts_indirect_word_source() + { + var a = AbLegacyAddress.TryParse("N7:[N7:0]"); + a.ShouldNotBeNull(); + a.FileLetter.ShouldBe("N"); + a.FileNumber.ShouldBe(7); + a.IndirectFileSource.ShouldBeNull(); + a.IndirectWordSource.ShouldNotBeNull(); + a.IndirectWordSource!.FileLetter.ShouldBe("N"); + a.IndirectWordSource.FileNumber.ShouldBe(7); + a.IndirectWordSource.WordNumber.ShouldBe(0); + a.IsIndirect.ShouldBeTrue(); + } + + [Fact] + public void TryParse_accepts_indirect_file_source() + { + var a = AbLegacyAddress.TryParse("N[N7:0]:5"); + a.ShouldNotBeNull(); + a.FileLetter.ShouldBe("N"); + a.FileNumber.ShouldBeNull(); + a.WordNumber.ShouldBe(5); + a.IndirectFileSource.ShouldNotBeNull(); + a.IndirectFileSource!.FileLetter.ShouldBe("N"); + a.IndirectFileSource.FileNumber.ShouldBe(7); + a.IndirectFileSource.WordNumber.ShouldBe(0); + a.IndirectWordSource.ShouldBeNull(); + a.IsIndirect.ShouldBeTrue(); + } + + [Fact] + public void TryParse_accepts_both_indirect_file_and_word() + { + var a = AbLegacyAddress.TryParse("N[N7:0]:[N7:1]"); + a.ShouldNotBeNull(); + a.IndirectFileSource.ShouldNotBeNull(); + a.IndirectWordSource.ShouldNotBeNull(); + a.IndirectWordSource!.WordNumber.ShouldBe(1); + } + + [Theory] + [InlineData("N[N[N7:0]:0]:5")] // depth-2 file source + [InlineData("N7:[N[N7:0]:0]")] // depth-2 word source + [InlineData("N7:[N7:[N7:0]]")] // depth-2 word source (nested word) + public void TryParse_rejects_depth_greater_than_one(string input) + { + AbLegacyAddress.TryParse(input).ShouldBeNull(); + } + + [Theory] + [InlineData("N7:[")] // unbalanced bracket + [InlineData("N7:]")] // unbalanced bracket + [InlineData("N[:5")] // empty inner file source + [InlineData("N7:[]")] // empty inner word source + [InlineData("N[X9:0]:5")] // unknown file letter inside + public void TryParse_rejects_malformed_indirect(string input) + { + AbLegacyAddress.TryParse(input).ShouldBeNull(); + } + + [Fact] + public void ToLibplctagName_reemits_indirect_word_source() + { + var a = AbLegacyAddress.TryParse("N7:[N7:0]"); + a.ShouldNotBeNull(); + a.ToLibplctagName().ShouldBe("N7:[N7:0]"); + } + + [Fact] + public void ToLibplctagName_reemits_indirect_file_source() + { + var a = AbLegacyAddress.TryParse("N[N7:0]:5"); + a.ShouldNotBeNull(); + a.ToLibplctagName().ShouldBe("N[N7:0]:5"); + } + + [Fact] + public void TryParse_indirect_with_bit_outside_brackets() + { + // Outer bit applies to the resolved word; inner address is still depth-1. + var a = AbLegacyAddress.TryParse("N7:[N7:0]/3"); + a.ShouldNotBeNull(); + a.BitIndex.ShouldBe(3); + a.IndirectWordSource.ShouldNotBeNull(); + a.ToLibplctagName().ShouldBe("N7:[N7:0]/3"); + } + + [Fact] + public void TryParse_Plc5_indirect_inner_address_obeys_octal() + { + // Inner I:/O: indices on PLC-5 must obey octal rules even when nested in brackets. + var a = AbLegacyAddress.TryParse("N7:[I:010/10]", AbLegacyPlcFamily.Plc5); + a.ShouldNotBeNull(); + a.IndirectWordSource.ShouldNotBeNull(); + a.IndirectWordSource!.WordNumber.ShouldBe(8); // octal 010 → 8 + a.IndirectWordSource.BitIndex.ShouldBe(8); // octal 10 → 8 + + // Octal-illegal digit '8' inside an inner I: address is rejected on PLC-5. + AbLegacyAddress.TryParse("N7:[I:8/0]", AbLegacyPlcFamily.Plc5).ShouldBeNull(); + } + + [Fact] + public void TryParse_indirect_inner_cannot_itself_be_indirect() + { + AbLegacyAddress.TryParse("N7:[N7:[N7:0]]").ShouldBeNull(); + AbLegacyAddress.TryParse("N[N[N7:0]:5]:5").ShouldBeNull(); + } + [Theory] [InlineData("RTC", "HR", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)] [InlineData("RTC", "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]