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)]