fix(ablegacy): translate S/O/I and R status bits for libplctag's PCCC parser (#659)

libplctag 2.6.0 (bundled by libplctag 1.5.2 / NativeImport 1.0.41) pccc.c needs an
explicit file number (parse_pccc_file_num; S is only the status file when a digit
follows) and has no R-file status-bit mnemonics (sub_element_lookup: con/len/pos only;
a /bit is refused on a 6-byte element). So the canonical S:0 (the default probe),
O:0/3, I:0/0 and R6:0.DN failed with BadCommunicationError on a real MicroLogix 1400
while native read them.

LibplctagLegacyName (libplctag adapter only) now emits S2:/O0:/I1: (the native
encoder's implicit file numbers) and R6:0.CON/<bit> for status bits libplctag lacks
(R EN/EU/DN/EM/ER/UL/IN/FD, C UA), bit numbers taken from the native table. The
authored grammar is unchanged. Unit tests pin the translation per family and check,
through a model of the pccc.c parser, that the translated name addresses exactly
what the native encoder does (46 fail without the fix). Live: Z-33323 idle, reads
only - libplctag now equals native for S/O/I/R status bits; controls unchanged.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 05:34:09 -04:00
parent e0e8c4267a
commit 0abce36ca3
6 changed files with 404 additions and 14 deletions
@@ -0,0 +1,238 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Gitea #659: libplctag 2.6.0's PCCC parser (<c>src/protocols/ab/pccc.c</c>) needs an explicit file number
/// for O/I/S and has no R-file status-bit mnemonics, so the canonical <c>S:0</c>, <c>O:0/3</c>, <c>I:0/0</c>
/// and <c>R6:0.DN</c> failed on a real MicroLogix 1400 while native read them. These tests pin the translation
/// and — through <see cref="Pccc"/>, a model of that parser — that the translated name addresses exactly what
/// the native encoder (proven on hardware) does.
/// </summary>
public sealed class LibplctagLegacyNameTests
{
[Theory]
[InlineData("S:0", "S2:0")]
[InlineData("S:57", "S2:57")]
[InlineData("S:4", "S2:4")]
[InlineData("S:1/5", "S2:1/5")]
[InlineData("O:0", "O0:0")]
[InlineData("O:0/3", "O0:0/3")]
[InlineData("I:0", "I1:0")]
[InlineData("I:0/0", "I1:0/0")]
[InlineData("I:0/1", "I1:0/1")]
[InlineData("R6:0.EN", "R6:0.CON/15")]
[InlineData("R6:0.EU", "R6:0.CON/14")]
[InlineData("R6:0.DN", "R6:0.CON/13")]
[InlineData("R6:0.EM", "R6:0.CON/12")]
[InlineData("R6:0.ER", "R6:0.CON/11")]
[InlineData("R6:0.UL", "R6:0.CON/10")]
[InlineData("R6:0.IN", "R6:0.CON/9")]
[InlineData("R6:0.FD", "R6:0.CON/8")]
[InlineData("C5:0.UA", "C5:0.CON/10")]
public void Translates_the_forms_libplctag_cannot_parse(string canonical, string expected) =>
LibplctagLegacyName.Translate(canonical).ShouldBe(expected);
[Theory]
[InlineData("N9:0")]
[InlineData("N10:150/0")]
[InlineData("F8:226")]
[InlineData("B3:28")]
[InlineData("B3:0/4")]
[InlineData("L9:0")]
[InlineData("ST14:10")]
[InlineData("T4:15.PRE")]
[InlineData("T4:15.ACC")]
[InlineData("T4:15.DN")]
[InlineData("T4:15.EN")]
[InlineData("T4:15.TT")]
[InlineData("C5:8.DN")]
[InlineData("C5:8.CU")]
[InlineData("C5:8.OV")]
[InlineData("R6:0.LEN")]
[InlineData("R6:0.POS")]
public void Leaves_names_libplctag_already_reads_unchanged(string canonical) =>
LibplctagLegacyName.Translate(canonical).ShouldBe(canonical);
[Theory]
[InlineData("RTC:0.HR")] // MicroLogix function file — not a data-table address
[InlineData("N7:[N7:0]")] // indirect — the driver resolves it before any transport sees it
[InlineData("R6:0.FOO")] // a mnemonic neither transport knows
[InlineData("garbage")]
[InlineData("")]
public void Passes_anything_it_cannot_parse_through_unchanged(string canonical) =>
LibplctagLegacyName.Translate(canonical).ShouldBe(canonical);
/// <summary>Every family emits a canonical name the same way, so each family's translation is exercised end to
/// end from the AUTHORED text — including PLC-5's octal I/O, which the parser normalises to decimal first.</summary>
[Theory]
[InlineData(AbLegacyPlcFamily.MicroLogix, "I:0/0", "I1:0/0")]
[InlineData(AbLegacyPlcFamily.MicroLogix, "S:0", "S2:0")]
[InlineData(AbLegacyPlcFamily.Slc500, "O:2/15", "O0:2/15")]
[InlineData(AbLegacyPlcFamily.Slc500, "R6:3.DN", "R6:3.CON/13")]
[InlineData(AbLegacyPlcFamily.Plc5, "I:017/07", "I1:15/7")]
[InlineData(AbLegacyPlcFamily.Plc5, "O:010/17", "O0:8/15")]
[InlineData(AbLegacyPlcFamily.Plc5, "S:0", "S2:0")]
[InlineData(AbLegacyPlcFamily.LogixPccc, "S:0", "S2:0")]
[InlineData(AbLegacyPlcFamily.LogixPccc, "N7:0", "N7:0")]
public void Translates_each_familys_authored_address(AbLegacyPlcFamily family, string authored, string expected)
{
var canonical = AbLegacyAddress.TryParse(authored, family)!.ToLibplctagName();
LibplctagLegacyName.Translate(canonical).ShouldBe(expected);
}
/// <summary>The name actually handed to libplctag (the tag built for a runtime) is the translated one.</summary>
[Fact]
public void The_libplctag_tag_is_created_with_the_translated_name()
{
using var tag = LibplctagLegacyTagRuntime.BuildTag(new AbLegacyTagCreateParams(
Gateway: "10.100.0.35", Port: 44818, CipPath: "", LibplctagPlcAttribute: "micrologix",
TagName: "S:0", Timeout: TimeSpan.FromSeconds(5)));
tag.Name.ShouldBe("S2:0");
}
public static TheoryData<string> CanonicalAddresses() =>
[
"S:0", "S:57", "S:4", "S:1/5", "O:0", "O:0/3", "I:0", "I:0/0", "I:0/1", "I:3/15",
"R6:0.EN", "R6:0.EU", "R6:0.DN", "R6:0.EM", "R6:0.ER", "R6:0.UL", "R6:0.IN", "R6:0.FD", "R6:0.LEN", "R6:0.POS",
"C5:0.UA", "C5:8.DN", "C5:8.CU", "C5:8.CD", "C5:8.OV", "C5:8.UN", "C5:8.PRE", "C5:8.ACC",
"T4:15.EN", "T4:15.TT", "T4:15.DN", "T4:15.PRE", "T4:15.ACC",
"N9:0", "N10:150/0", "F8:226", "B3:28", "B3:0/4", "ST14:10", "L9:0", "N7:300",
];
/// <summary>
/// The load-bearing parity check: the translated name, run through a model of libplctag 2.6.0's parser, must
/// yield the same file type, file number, element, sub-element word and bit as the native transport's
/// encoder — the one with real-hardware evidence (MicroLogix 1400 Z-33323, 2026-10-09).
/// </summary>
[Theory]
[MemberData(nameof(CanonicalAddresses))]
public void The_translated_name_addresses_what_the_native_transport_addresses(string canonical)
{
var parsed = AbLegacyAddress.TryParse(canonical)!;
var native = parsed.SubElement is { } sub
? PcccAddress.WithSubElementName(parsed.FileLetter, parsed.FileNumber, parsed.WordNumber, sub)
: new PcccAddress(parsed.FileLetter, parsed.FileNumber, parsed.WordNumber, null, parsed.BitIndex);
var lib = Pccc.Parse(LibplctagLegacyName.Translate(canonical));
lib.ShouldNotBeNull($"libplctag would reject '{LibplctagLegacyName.Translate(canonical)}'");
lib.Value.FileType.ShouldBe(native.FileTypeCode);
lib.Value.File.ShouldBe(native.EffectiveFileNumber);
lib.Value.Element.ShouldBe(native.Element);
Math.Max(lib.Value.SubElement, 0).ShouldBe(native.ResolveSubElement().Word);
lib.Value.Bit.ShouldBe(native.Bit);
}
/// <summary>Falsification control: the UNtranslated canonical names are exactly the ones the hardware gate saw
/// fail — so the model reproduces the defect, and the parity test above is not passing vacuously.</summary>
[Theory]
[InlineData("S:0")]
[InlineData("S:57")]
[InlineData("O:0/3")]
[InlineData("I:0")]
[InlineData("I:0/0")]
[InlineData("R6:0.DN")]
[InlineData("R6:0.EN")]
[InlineData("R6:0/13")]
public void The_untranslated_canonical_names_are_rejected_by_libplctag(string canonical) =>
Pccc.Parse(canonical).ShouldBeNull();
[Theory]
[InlineData("N9:0")]
[InlineData("T4:15.DN")]
[InlineData("C5:8.DN")]
[InlineData("R6:0.LEN")]
[InlineData("B3:0/4")]
public void The_model_accepts_the_names_that_read_on_hardware(string name) => Pccc.Parse(name).ShouldNotBeNull();
/// <summary>
/// A line-for-line model of libplctag v2.6.0 <c>parse_pccc_logical_address</c> (<c>src/protocols/ab/pccc.c</c>
/// L161-198: file type L695-877, file number L881-904 — a digit is REQUIRED, element L908-933, sub-element
/// mnemonic table L936-1064 + L1067-1131, bit L1134-1188 — only on a 2-byte element) for the data-table files
/// this driver authors. Null where libplctag returns an error.
/// </summary>
private static class Pccc
{
public static (byte FileType, int File, int Element, int SubElement, int? Bit)? Parse(string name)
{
var s = name;
var i = 0;
char At(int k) => k < s.Length ? s[k] : '\0';
// parse_pccc_file_type: S and B mean status/bit only when a digit follows.
byte type;
int size;
switch (char.ToUpperInvariant(At(0)))
{
case 'B' when char.IsDigit(At(1)): type = 0x85; size = 2; i = 1; break;
case 'C': type = 0x87; size = 6; i = 1; break;
case 'F': type = 0x8A; size = 4; i = 1; break;
case 'I': type = 0x8C; size = 2; i = 1; break;
case 'L': type = 0x91; size = 4; i = 1; break;
case 'N': type = 0x89; size = 2; i = 1; break;
case 'O': type = 0x8B; size = 2; i = 1; break;
case 'R': type = 0x88; size = 6; i = 1; break;
case 'S' when char.IsDigit(At(1)): type = 0x84; size = 2; i = 1; break;
case 'S' when char.ToUpperInvariant(At(1)) == 'T': type = 0x8D; size = 84; i = 2; break;
case 'T': type = 0x86; size = 6; i = 1; break;
default: return null;
}
// parse_pccc_file_num: a digit is required.
if (!char.IsDigit(At(i))) return null;
var file = 0;
while (char.IsDigit(At(i))) file = (file * 10) + (s[i++] - '0');
// parse_pccc_elem_num
if (At(i) != ':') return null;
i++;
var element = 0;
while (char.IsDigit(At(i))) element = (element * 10) + (s[i++] - '0');
// parse_pccc_subelem_mnemonic
var sub = -1;
int? bit = null;
if (At(i) == '.')
{
i++;
var match = Table.FirstOrDefault(e => e.Type == type
&& string.Compare(s, i, e.Field, 0, e.Field.Length, StringComparison.OrdinalIgnoreCase) == 0);
if (match.Field is null) return null;
sub = match.Sub;
size = match.Size;
if (match.IsBit) bit = match.BitNum;
i += match.Field.Length;
}
else if (At(i) != '\0' && At(i) != '/') return null;
// parse_pccc_bit_num
if (At(i) == '/')
{
if (size != 2) return null;
i++;
var b = 0;
while (char.IsDigit(At(i))) b = (b * 10) + (s[i++] - '0');
if (b > 15) return null;
bit = b;
}
else if (At(i) != '\0') return null;
return (type, file, element, sub, bit);
}
private static readonly (byte Type, string Field, int Size, int Sub, bool IsBit, int BitNum)[] Table =
[
(0x88, "con", 2, 0, false, 0), (0x88, "len", 2, 1, false, 0), (0x88, "pos", 2, 2, false, 0),
(0x87, "con", 2, 0, false, 0), (0x87, "cu", 2, 0, true, 15), (0x87, "cd", 2, 0, true, 14),
(0x87, "dn", 2, 0, true, 13), (0x87, "ov", 2, 0, true, 12), (0x87, "un", 2, 0, true, 11),
(0x87, "pre", 2, 1, false, 0), (0x87, "acc", 2, 2, false, 0),
(0x8D, "len", 2, 0, false, 0), (0x8D, "data", 82, 1, false, 0),
(0x86, "con", 2, 0, false, 0), (0x86, "en", 2, 0, true, 15), (0x86, "tt", 2, 0, true, 14),
(0x86, "dn", 2, 0, true, 13), (0x86, "pre", 2, 1, false, 0), (0x86, "acc", 2, 2, false, 0),
];
}
}