From 0abce36ca32936b32b2a1ef59e0e70863f167032 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 9 Oct 2026 05:34:09 -0400 Subject: [PATCH] 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/ 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 --- CLAUDE.md | 6 +- docs/drivers/AbLegacy.md | 29 ++- ...-10-07-native-ab-transport-gate-results.md | 53 ++++ .../LibplctagLegacyName.cs | 83 ++++++ .../LibplctagLegacyTagRuntime.cs | 9 +- .../LibplctagLegacyNameTests.cs | 238 ++++++++++++++++++ 6 files changed, 404 insertions(+), 14 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyName.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyNameTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 6b0a85a0..fc2c1c63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1090,8 +1090,10 @@ re-emit a concrete default. The defaults differ per driver (final review, 2026-1 CompactLogix (Z-35162); scalar native writes (DINT/BOOL/REAL) proven on it once (2026-10-09, user-approved one-off, restored) — and native UDT member writes (DINT/BIT/REAL, #611 gate, same day); array/STRING, libplctag member and fragmented writes are `ab_server`-only. - **AbLegacy: an absent key resolves to `Libplctag` for every family; `Native` is opt-in** (no node key). On a real MicroLogix 1400 (Z-33323, idle, 2026-10-09) native read S/N/F/B/T/C/R/ST/O/I incl. `S:0`, T/C/R - sub-elements and status bits, and ST byte order, all correct, and wrote N/F8/B-bit (restored); ⚠️ **libplctag (the - default) fails `S:`/`O:`/`I:` and control status bits there** — the default probe `S:0` fails on real hardware. + sub-elements and status bits, and ST byte order, all correct, and wrote N/F8/B-bit (restored). libplctag (the default) + used to fail `S:`/`O:`/`I:` and `R` status bits there (incl. the default probe `S:0`) — its `pccc.c` needs explicit + file numbers and has no R status-bit mnemonics; since #659 `LibplctagLegacyName` translates (`S2:`/`O0:`/`I1:`, + `R6:0.CON/13`) and both transports read identically on that press. The authored grammar is unchanged. L/A files, function files, SLC, PLC-5 and `logixpccc` remain unverified natively. Explicit Native on PLC-5/`LogixPccc` logs a once-per-instance Warning. AbLegacy native refuses status-bit sub-element writes (`T4:0.DN`…, `BadNotSupported`). diff --git a/docs/drivers/AbLegacy.md b/docs/drivers/AbLegacy.md index 93616002..b2970dfc 100644 --- a/docs/drivers/AbLegacy.md +++ b/docs/drivers/AbLegacy.md @@ -194,8 +194,8 @@ Native routing: SLC500/MicroLogix/PLC-5 are direct; `logixpccc` uses Unconnected follow-up). PLC-5 writes are refused the same way. libplctag performs a masked bit write here; a native read-modify-write of the control word would race the PLC updating TT/DN. Reads of status bits are unaffected. - ⚠️ **On libplctag (the default) a status-bit write is NOT refused** — it goes to the controller as a write to the - timer/counter/control element (libplctag's bit-tag write; not exercised on a wire — `ab_server` has no T/C/R - files). Nothing in authoring prevents it: the address is free text, + timer/counter/control element (libplctag's masked bit-tag write; an `R` status bit is sent as `R:.CON/` + since #659; not exercised on a wire — `ab_server` has no T/C/R files). Nothing in authoring prevents it: the address is free text, any `dataType` may be chosen (a status bit is naturally authored `Bit`), and `writable` defaults to `true`. Author status-bit tags with `"writable": false` unless writing them is genuinely intended. - **PLC-5 native is N-file reads only**; every other PLC-5 operation is refused with `BadNotSupported`. @@ -216,25 +216,36 @@ before and after). Read pass, each address on libplctag then native (raw output | Address(es) | libplctag | Native | |---|---|---| -| `S:0` (default probe), `S:57` (= 1400), `S:4` | **BadCommunicationError** | Good (4, 1400, clock) | +| `S:0` (default probe), `S:57` (= 1400), `S:4` | Good, equal (4, 1400, clock) since #659; **BadCommunicationError** before | Good (4, 1400, clock) | | `N9:0`, `N9:4`, `N7:255` | Good | Good, equal | | `F8:226` (2.1), `F8:214` (200.0) | Good | Good, equal | | `B3:24`, `B3:28` (0x0905), bits `/0`, `/8`, `/9`, `B3:132/14` | Good | Good, equal | | `T4:15/93/14/11.PRE`, `.ACC`, `.DN`; `T40:3/12.PRE` | Good | Good, equal | | `C5:0.PRE/.ACC`, `C5:1.PRE`, `C5:8.DN` | Good | Good, equal | | `R6:0.LEN`, `.POS` | Good | Good, equal | -| `R6:0.DN` (and `.EN`) | **BadCommunicationError** | Good | +| `R6:0.DN` (and `.EN`) | Good, equal since #659; **BadCommunicationError** before | Good | | `ST14:10` (`"Z-33323"`), `ST14:0` | Good | Good, equal — **ST byte order correct** | -| `O:0`, `O:0/3`, `I:0`, `I:0/0` | **BadCommunicationError** | Good | +| `O:0`, `O:0/3`, `I:0`, `I:0/0`, `I:0/1`, `I:0/6`, `I:0/15` | Good, equal since #659; **BadCommunicationError** before | Good | Native writes on the same idle press, each restored to 0 and verified: `N7:23` 0→4242→0 (neighbours `N7:22/24` unchanged), `F8:190` 0→42.5→0, `B3:200` bit RMW `/5` set → 32, `/3` set → 40, `/5` clear → 8, `/3` clear → 0 (neighbours `B3:199/201` unchanged). Addresses vetted against the sibling Z-33756 program export (no ladder, symbol, HMI, panel or migration reference) and Z-33323's own symbol table, and read 0 beforehand. -⚠️ **libplctag (the default) cannot read `S`, `O`, `I` or control status bits on this controller**, and the -numbered forms (`S2:0`, `O0:0`, `I1:0`) are rejected by the address parser — so the default probe `S:0` fails on a -real MicroLogix under the default transport (tracked as a follow-up issue). Not tested on hardware: L and A files -(no press has them), function files, PLC-5, SLC, `logixpccc`. +**libplctag `S`/`O`/`I` and control status bits — fixed (#659).** Before the fix libplctag (the default) returned +`BadCommunicationError` for `S:`, `O:`, `I:` and `R6:0.DN`/`.EN` on this controller, so the default probe `S:0` +failed on real hardware. Root cause, from libplctag 2.6.0's `src/protocols/ab/pccc.c` (the core bundled by the +`libplctag` 1.5.2 package): `parse_pccc_file_num` requires a digit after the file letter (and `S` is only the +status file when a digit follows it), and its sub-element table has only `con`/`len`/`pos` for R files — a bare +`R6:0/13` is refused too, because a `/bit` is only allowed on a 2-byte element. The libplctag adapter +(`LibplctagLegacyName`) now translates the canonical address into the form that parser accepts — `S:n` → `S2:n`, +`O:n` → `O0:n`, `I:n` → `I1:n` (the same default file numbers the native encoder uses, on every family) and a status +bit libplctag lacks (`R` `EN/EU/DN/EM/ER/UL/IN/FD`, `C` `UA`) → `R6:0.CON/` (control word + bit, bit numbers +from the native table). The authored grammar is unchanged; `S2:0`/`O0:0`/`I1:0` stay rejected by the parser (one +spelling per address). Re-run on the same idle press 2026-10-09: every address above reads **identical** on both +transports (`S:4` is a free-running clock and differs read to read on each). ⚠️ The translation also means a +libplctag **write** to an `R` status bit now reaches the controller as libplctag's masked bit write (it used to fail +at tag creation) — the same behaviour T/C status bits always had on libplctag; see the status-bit write note above. +Not tested on hardware: L and A files (no press has them), function files, PLC-5, SLC, `logixpccc`. Simulator evidence: diff --git a/docs/plans/2026-10-07-native-ab-transport-gate-results.md b/docs/plans/2026-10-07-native-ab-transport-gate-results.md index 717a534d..c337bad4 100644 --- a/docs/plans/2026-10-07-native-ab-transport-gate-results.md +++ b/docs/plans/2026-10-07-native-ab-transport-gate-results.md @@ -647,3 +647,56 @@ pre: cycle=falseG pump=falseG -- neighbours: B3:199=0G B3:201=0G post: cycle=falseG pump=falseG N7:23=0G F8:190=0G B3:200=0G ``` + +### 2026-10-09 (later) — libplctag S/O/I + control status bits fixed (#659), re-read on Z-33323 + +Root cause, from libplctag v2.6.0 `src/protocols/ab/pccc.c` (the native core inside `libplctag.NativeImport` 1.0.41, pulled by the `libplctag` 1.5.2 package): `parse_pccc_file_num` (L881) requires a digit after the file letter and `parse_pccc_file_type` (L828-835) only treats `S` as the status file when a digit follows, so `S:0`/`O:0`/`I:0` never parse; `sub_element_lookup` (L936-1064) has only `con`/`len`/`pos` for R files, and `parse_pccc_bit_num` (L1162) refuses a `/bit` on a non-2-byte element, so neither `R6:0.DN` nor `R6:0/13` parses. The libplctag adapter now translates (`LibplctagLegacyName`): `S:n`→`S2:n`, `O:n`→`O0:n`, `I:n`→`I1:n` (the native encoder's implicit file numbers) and an unknown status bit → `R6:0.CON/`. Press idle beforehand (`N10:150/0` = `B3:0/4` = false, Native) and afterwards (both false again). AbLegacy CLI built from branch `i659`, reads only, one short session per read, ≥ 1.2 s apart: + +``` +S:0 Int Libplctag value=4 status=0x00000000 (Good) +S:0 Int Native value=4 status=0x00000000 (Good) +S:57 Int Libplctag value=1400 status=0x00000000 (Good) +S:57 Int Native value=1400 status=0x00000000 (Good) +S:4 Int Libplctag value=12688 status=0x00000000 (Good) +S:4 Int Native value=-31956 status=0x00000000 (Good) +O:0 Int Libplctag value=0 status=0x00000000 (Good) +O:0 Int Native value=0 status=0x00000000 (Good) +O:0/3 Bit Libplctag value=false status=0x00000000 (Good) +O:0/3 Bit Native value=false status=0x00000000 (Good) +I:0 Int Libplctag value=-29376 status=0x00000000 (Good) +I:0 Int Native value=-29376 status=0x00000000 (Good) +I:0/0 Bit Libplctag value=false status=0x00000000 (Good) +I:0/0 Bit Native value=false status=0x00000000 (Good) +I:0/1 Bit Libplctag value=false status=0x00000000 (Good) +I:0/1 Bit Native value=false status=0x00000000 (Good) +R6:0.DN Bit Libplctag value=false status=0x00000000 (Good) +R6:0.DN Bit Native value=false status=0x00000000 (Good) +R6:0.EN Bit Libplctag value=false status=0x00000000 (Good) +R6:0.EN Bit Native value=false status=0x00000000 (Good) +R6:0.LEN Int Libplctag value=32 status=0x00000000 (Good) +R6:0.LEN Int Native value=32 status=0x00000000 (Good) +N9:0 Int Libplctag value=2331 status=0x00000000 (Good) +N9:0 Int Native value=2331 status=0x00000000 (Good) +F8:226 Float Libplctag value=2.1 status=0x00000000 (Good) +F8:226 Float Native value=2.1 status=0x00000000 (Good) +B3:28 Int Libplctag value=2309 status=0x00000000 (Good) +B3:28 Int Native value=2309 status=0x00000000 (Good) +T4:15.PRE Int Libplctag value=6000 status=0x00000000 (Good) +T4:15.PRE Int Native value=6000 status=0x00000000 (Good) +C5:8.DN Bit Libplctag value=true status=0x00000000 (Good) +C5:8.DN Bit Native value=true status=0x00000000 (Good) +ST14:10 String Libplctag value="Z-33323" status=0x00000000 (Good) +ST14:10 String Native value="Z-33323" status=0x00000000 (Good) +05:31:09 I:0/6 Libplctag Value: true | Status: 0x00000000 (Good) +05:31:11 I:0/6 Native Value: true | Status: 0x00000000 (Good) +05:31:13 I:0/15 Libplctag Value: true | Status: 0x00000000 (Good) +05:31:16 I:0/15 Native Value: true | Status: 0x00000000 (Good) +05:31:18 I:0/7 Libplctag Value: false | Status: 0x00000000 (Good) +05:31:20 I:0/7 Native Value: false | Status: 0x00000000 (Good) +05:31:22 S:4 Libplctag Value: 17831 | Status: 0x00000000 (Good) +05:31:24 S:4 Native Value: -26549 | Status: 0x00000000 (Good) +05:31:26 S:4 Libplctag Value: -4646 | Status: 0x00000000 (Good) +05:31:28 S:4 Native Value: 17335 | Status: 0x00000000 (Good) +``` + +Every address reads identically on both transports. `S:4` is the free-running clock, so its value differs read to read on each transport (both Good). `I:0` = -29376 = 0x8D40, and bits `/6` and `/15` read true on both while `/0`, `/1`, `/7` read false — the bit is extracted from the word libplctag read, not defaulted. ab_server has no S/O/I files, so this real-controller read is the only wire evidence; the translation is unit-pinned against a model of the `pccc.c` parser (`LibplctagLegacyNameTests`). diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyName.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyName.cs new file mode 100644 index 00000000..b4bac25d --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyName.cs @@ -0,0 +1,83 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +/// +/// Translates a canonical AbLegacy address ( — what operators +/// author and the native transport parses) into the name= form libplctag's PCCC parser accepts, so both +/// transports address the same file type / file number / element / sub-element / bit (Gitea #659). +/// +/// +/// libplctag 2.6.0 (libplctag.NativeImport 1.0.41, bundled by the libplctag 1.5.2 package), +/// src/protocols/ab/pccc.c, has two gaps the canonical grammar falls into — both proven on a real +/// MicroLogix 1400 (Z-33323, 2026-10-09), where the untranslated names failed while native read them: +/// +/// No implicit file number. parse_pccc_file_num requires a digit after the file letter, +/// and parse_pccc_file_type only recognises S as the status file when a digit follows it. So +/// S:0, O:0/3, I:0/0 fail to parse. They are emitted with the PCCC default file numbers +/// the native encoder uses (): O0, I1, S2 +/// — the same numbers on SLC 500, MicroLogix and PLC-5. +/// Control status bits are missing from its sub-element table. sub_element_lookup lists +/// only con/len/pos for R files (and no ua for counters), and a bare +/// R6:0/13 is refused because parse_pccc_bit_num allows a bit only on a 2-byte element. A +/// status bit libplctag does not know is therefore emitted as control word + bit, R6:0.CON/13, with +/// the bit number taken from the native transport's own table +/// () so the two transports cannot disagree. +/// +/// Everything else passes through unchanged — including names libplctag already reads correctly +/// (T4:0.DN, C5:0.ACC, R6:0.LEN), and names this translator cannot parse (function files, +/// indirect forms, unknown sub-elements), which keep failing in libplctag exactly as before. +/// +internal static class LibplctagLegacyName +{ + /// The sub-element mnemonics libplctag 2.6.0's sub_element_lookup table knows, per file letter. + private static bool LibplctagKnowsSubElement(string letter, string sub) => (letter, sub) switch + { + ("T", "CON" or "EN" or "TT" or "DN" or "PRE" or "ACC") => true, + ("C", "CON" or "CU" or "CD" or "DN" or "OV" or "UN" or "PRE" or "ACC") => true, + ("R", "CON" or "LEN" or "POS") => true, + _ => false, + }; + + /// Returns the libplctag tag name for a canonical AbLegacy address. + /// The canonical (decimal-normalised) address, e.g. S:0 or R6:0.DN. + /// The libplctag name= value, e.g. S2:0 or R6:0.CON/13. + public static string Translate(string canonical) + { + // Family-neutral parse is correct: the driver hands the runtime AbLegacyAddress.ToLibplctagName(), already + // decimal-normalised (PLC-5 octal I/O included) — the same reasoning as NativeAbLegacyTagRuntime.ToPcccAddress. + if (AbLegacyAddress.TryParse(canonical) is not { IsIndirect: false } a) return canonical; + + var fileNumber = a.FileNumber ?? PcccFileType.ImplicitFileNumber(a.FileLetter); + if (fileNumber is null) return canonical; + + var name = $"{a.FileLetter}{fileNumber}:{a.WordNumber}"; + if (a.SubElement is { } sub) + { + if (LibplctagKnowsSubElement(a.FileLetter, sub)) + { + name += $".{sub}"; + } + else + { + PcccAddress native; + try + { + native = PcccAddress.WithSubElementName(a.FileLetter, fileNumber, a.WordNumber, sub); + } + catch (AbTransportException) + { + return canonical; // a mnemonic neither transport knows; libplctag rejects it as before + } + + // Only status bits live in the control word; a word sub-element libplctag lacks has no bit form. + if (native is not { SubElement: 0, Bit: int bit }) return canonical; + name += $".CON/{bit}"; + } + } + + if (a.BitIndex is int b) name += $"/{b}"; + return name; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs index 96634516..19cf406f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs @@ -7,8 +7,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; /// /// Default libplctag-backed . Uses ab_pccc protocol /// on top of EtherNet/IP — libplctag's PCCC layer handles the file-letter + word + bit + -/// sub-element decoding internally, so our wrapper just has to forward the atomic type to -/// the right Get/Set call. +/// sub-element decoding internally, once has put the canonical +/// address in the form its parser accepts; our wrapper then forwards the atomic type to the +/// right Get/Set call. /// internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime { @@ -41,7 +42,9 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime Path = LibplctagPath.Normalize(p.CipPath), PlcType = MapPlcType(p.LibplctagPlcAttribute), Protocol = Protocol.ab_eip, // PCCC-over-EIP; libplctag routes via the PlcType-specific PCCC layer - Name = p.TagName, + // libplctag's PCCC parser needs explicit O0/I1/S2 file numbers and has no R-file status-bit mnemonics; + // translate so it addresses what the native transport does (#659). + Name = LibplctagLegacyName.Translate(p.TagName), Timeout = p.Timeout, ElementCount = p.ElementCount > 1 ? p.ElementCount : null, }; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyNameTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyNameTests.cs new file mode 100644 index 00000000..e3b93454 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyNameTests.cs @@ -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; + +/// +/// Gitea #659: libplctag 2.6.0's PCCC parser (src/protocols/ab/pccc.c) needs an explicit file number +/// for O/I/S and has no R-file status-bit mnemonics, so the canonical S:0, O:0/3, I:0/0 +/// and R6:0.DN failed on a real MicroLogix 1400 while native read them. These tests pin the translation +/// and — through , a model of that parser — that the translated name addresses exactly what +/// the native encoder (proven on hardware) does. +/// +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); + + /// 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. + [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); + } + + /// The name actually handed to libplctag (the tag built for a runtime) is the translated one. + [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 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", + ]; + + /// + /// 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). + /// + [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); + } + + /// 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. + [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(); + + /// + /// A line-for-line model of libplctag v2.6.0 parse_pccc_logical_address (src/protocols/ab/pccc.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. + /// + 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), + ]; + } +}