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? FileNumber,
|
||||||
int WordNumber,
|
int WordNumber,
|
||||||
int? BitIndex,
|
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()
|
public string ToLibplctagName()
|
||||||
{
|
{
|
||||||
var file = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
|
// Re-emit using bracket form when indirect. libplctag's PCCC text decoder does not
|
||||||
var wordPart = $"{file}:{WordNumber}";
|
// 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 (SubElement is not null) wordPart += $".{SubElement}";
|
||||||
if (BitIndex is not null) wordPart += $"/{BitIndex}";
|
if (BitIndex is not null) wordPart += $"/{BitIndex}";
|
||||||
return wordPart;
|
return wordPart;
|
||||||
@@ -53,6 +81,11 @@ public sealed record AbLegacyAddress(
|
|||||||
/// record stores decimal values; <see cref="ToLibplctagName"/> emits decimal too, which
|
/// record stores decimal values; <see cref="ToLibplctagName"/> emits decimal too, which
|
||||||
/// is what libplctag's PCCC layer expects.
|
/// is what libplctag's PCCC layer expects.
|
||||||
/// </summary>
|
/// </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)
|
public static AbLegacyAddress? TryParse(string? value, AbLegacyPlcFamily? family)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
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
|
// 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.
|
// I:/O: bit indices are octal in RSLogix 5, everything else is decimal.
|
||||||
string? bitText = null;
|
string? bitText = null;
|
||||||
var slashIdx = src.IndexOf('/');
|
var slashIdx = src.LastIndexOf('/');
|
||||||
if (slashIdx >= 0)
|
if (slashIdx >= 0 && slashIdx > src.LastIndexOf(']'))
|
||||||
{
|
{
|
||||||
bitText = src[(slashIdx + 1)..];
|
bitText = src[(slashIdx + 1)..];
|
||||||
src = src[..slashIdx];
|
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.)
|
// 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;
|
string? subElement = null;
|
||||||
var dotIdx = src.LastIndexOf('.');
|
var dotIdx = LastIndexOfTopLevel(src, '.');
|
||||||
if (dotIdx >= 0)
|
if (dotIdx >= 0)
|
||||||
{
|
{
|
||||||
var candidate = src[(dotIdx + 1)..];
|
var candidate = src[(dotIdx + 1)..];
|
||||||
@@ -88,23 +123,36 @@ public sealed record AbLegacyAddress(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var colonIdx = src.IndexOf(':');
|
var colonIdx = IndexOfTopLevel(src, ':');
|
||||||
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)..];
|
||||||
|
|
||||||
// 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;
|
if (filePart.Length == 0 || !char.IsLetter(filePart[0])) return null;
|
||||||
var letterEnd = 1;
|
var letterEnd = 1;
|
||||||
while (letterEnd < filePart.Length && char.IsLetter(filePart[letterEnd])) letterEnd++;
|
while (letterEnd < filePart.Length && char.IsLetter(filePart[letterEnd])) letterEnd++;
|
||||||
|
|
||||||
var letter = filePart[..letterEnd].ToUpperInvariant();
|
var letter = filePart[..letterEnd].ToUpperInvariant();
|
||||||
int? fileNumber = null;
|
int? fileNumber = null;
|
||||||
|
AbLegacyAddress? indirectFile = null;
|
||||||
if (letterEnd < filePart.Length)
|
if (letterEnd < filePart.Length)
|
||||||
{
|
{
|
||||||
if (!int.TryParse(filePart[letterEnd..], out var fn) || fn < 0) return null;
|
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;
|
fileNumber = fn;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reject unknown file letters — these cover SLC/ML/PLC-5 canonical families.
|
// Reject unknown file letters — these cover SLC/ML/PLC-5 canonical families.
|
||||||
// Function-file letters (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI) are MicroLogix-only.
|
// Function-file letters (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI) are MicroLogix-only.
|
||||||
@@ -115,7 +163,21 @@ public sealed record AbLegacyAddress(
|
|||||||
|
|
||||||
var octalForIo = profile?.OctalIoAddressing == true && (letter == "I" || letter == "O");
|
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;
|
int? bitIndex = null;
|
||||||
if (bitText is not null)
|
if (bitText is not null)
|
||||||
@@ -124,7 +186,53 @@ public sealed record AbLegacyAddress(
|
|||||||
bitIndex = bit;
|
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)
|
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(
|
?? throw new InvalidOperationException(
|
||||||
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");
|
$"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(
|
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
|
||||||
Gateway: device.ParsedAddress.Gateway,
|
Gateway: device.ParsedAddress.Gateway,
|
||||||
Port: device.ParsedAddress.Port,
|
Port: device.ParsedAddress.Port,
|
||||||
|
|||||||
@@ -205,6 +205,121 @@ public sealed class AbLegacyAddressTests
|
|||||||
AbLegacyPlcFamilyProfile.LogixPccc.SupportsFunctionFiles.ShouldBeFalse();
|
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]
|
[Theory]
|
||||||
[InlineData("RTC", "HR", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
|
[InlineData("RTC", "HR", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
|
||||||
[InlineData("RTC", "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
[InlineData("RTC", "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||||
|
|||||||
Reference in New Issue
Block a user