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:
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user