Merge pull request '[ablegacy] AbLegacy — Indirect/indexed addressing parser' (#324) from auto/ablegacy/4 into auto/driver-gaps
This commit was merged in pull request #324.
This commit is contained in:
@@ -32,12 +32,40 @@ public sealed record AbLegacyAddress(
|
||||
int? FileNumber,
|
||||
int WordNumber,
|
||||
int? BitIndex,
|
||||
string? SubElement)
|
||||
string? SubElement,
|
||||
AbLegacyAddress? IndirectFileSource = null,
|
||||
AbLegacyAddress? IndirectWordSource = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// True when either the file number or the word number is sourced from another PCCC
|
||||
/// address evaluated at runtime (PLC-5 / SLC indirect addressing — <c>N7:[N7:0]</c> or
|
||||
/// <c>N[N7:0]:5</c>). 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 <see cref="ToLibplctagName"/>.
|
||||
/// </summary>
|
||||
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; <see cref="ToLibplctagName"/> emits decimal too, which
|
||||
/// is what libplctag's PCCC layer expects.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Also accepts indirect / indexed forms (Issue #247): <c>N7:[N7:0]</c> reads file 7,
|
||||
/// word=value-of(N7:0); <c>N[N7:0]:5</c> reads file=value-of(N7:0), word 5. Recursion
|
||||
/// depth is capped at 1 — the inner address must be a plain direct PCCC address.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse an inner (bracketed) PCCC address with depth-1 cap. The inner address itself
|
||||
/// must NOT be indirect — nesting beyond one level is rejected.
|
||||
/// </summary>
|
||||
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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user