diff --git a/CLAUDE.md b/CLAUDE.md index 6b0a85a0..0d1e228f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1090,8 +1090,13 @@ 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. Read-side + only: a libplctag WRITE to any address the translation changes (`S:`/`O:`/`I:` incl. bit writes, R `EN…FD`, `C…UA`) + is refused `BadNotSupported` before any runtime is created (none could reach the PLC before — no write-surface + widening on the default transport); libplctag T/C status-bit writes it parses itself are 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..c445a270 100644 --- a/docs/drivers/AbLegacy.md +++ b/docs/drivers/AbLegacy.md @@ -194,8 +194,9 @@ 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 element (libplctag's masked bit-tag write; not exercised on a wire — `ab_server` has no T/C/R + files). This covers the status bits libplctag parses itself (`T` `EN/TT/DN`, `C` `CU/CD/DN/OV/UN`); the ones it + has no mnemonic for — every `R` status bit and `C…UA` — **are** refused on libplctag too (`BadNotSupported`, #659). 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 +217,42 @@ 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 is read-side only — it does not widen the write surface:** before it, libplctag could not create a +tag for `S:`/`O:`/`I:` or for an `R`/`C…UA` status bit, so no write to one ever reached the PLC. Now that the +translated names parse, a write would drive a physical output, a status-file word or (masked) a control word. So on +libplctag **every write whose address the translation changes** — `S:`, `O:`, `I:` (word and bit writes; a bit write +such as `O:0/3` is refused ahead of the parent-word read-modify-write) and the rewritten status bits (`R` +`EN/EU/DN/EM/ER/UL/IN/FD`, `C` `UA`) — is refused **`BadNotSupported` before a runtime is created**: nothing read or +sent, health unchanged (`LibplctagLegacyTagRuntime.WriteRefusal`, asked through `IAbLegacyTagFactory.WriteRefusal`, the +same plumbing as the native refusal). Those writes never worked on libplctag, so nothing is lost; the native +transport (opt-in) does write `S`/`O`/`I`. T/C status bits libplctag parses itself keep their pre-existing write behaviour (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..9ff57893 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,58 @@ 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`). + +Caveats: `R6:0.DN` and `R6:0.EN` read `false` on both transports, so this read does not discriminate the control-word bit number (a wrong bit would very likely also read false) — the R bit mapping rests on the native table and the `pccc.c` parser model, not on this wire evidence. Writes: on libplctag every address the translation changes (`S:`/`O:`/`I:`, R/UA status bits) is refused `BadNotSupported` (#659 review), so this fix adds no libplctag write path. 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..ec26ae7c --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyName.cs @@ -0,0 +1,98 @@ +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) => TranslateCore(canonical).Name; + + /// + /// True when changes : a file number supplied for + /// S:/O:/I: (incl. bit forms such as O:0/3) or a status bit rewritten to control word + + /// bit (R6:0.DN → R6:0.CON/13; R EN/EU/DN/EM/ER/UL/IN/FD, counter UA). A WRITE to such an + /// address is refused (): before #659 libplctag could not + /// even create these tags, so no write ever reached the PLC, and the read fix must not turn libplctag — the + /// default transport — into a new way to drive outputs, status-file words or control words. + /// + /// The canonical address. + /// when the libplctag name differs from the canonical one. + public static bool IsTranslated(string canonical) => TranslateCore(canonical).Changed; + + private static (string Name, bool Changed) TranslateCore(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, false); + + var fileNumber = a.FileNumber ?? PcccFileType.ImplicitFileNumber(a.FileLetter); + if (fileNumber is null) return (canonical, false); + + var fileNumberSupplied = a.FileNumber is null; // S:/O:/I: — libplctag needs S2/O0/I1 + 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, false); // 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, false); + return ($"{name}.CON/{bit}", true); // a sub-element never carries a /bit as well + } + } + + if (a.BitIndex is int b) name += $"/{b}"; + return (name, fileNumberSupplied); + } +} 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..d070ea96 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs @@ -7,13 +7,17 @@ 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 { private readonly Tag _tag; + /// Why a write to this tag is refused before the wire (), else null. + private readonly string? _writeRefusal; + /// /// The status the native tag held when a failed initialisation destroyed it (see /// ); null while the wrapper's own handle is authoritative. @@ -28,6 +32,7 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p) { _tag = BuildTag(p); + _writeRefusal = WriteRefusal(p); } /// Builds the (uninitialized) libplctag tag for . @@ -41,7 +46,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, }; @@ -56,8 +63,29 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime RunReleasingOrphanAsync(() => _tag.ReadAsync(cancellationToken)); /// + /// Throws the refusal (Unsupported) before anything is sent — defence in depth; + /// the driver asks the factory first and never creates a runtime for a refused write. public Task WriteAsync(CancellationToken cancellationToken) => - RunReleasingOrphanAsync(() => _tag.WriteAsync(cancellationToken)); + _writeRefusal is { } refusal + ? Task.FromException(new AbTransportException(AbStatus.Unsupported, refusal)) + : RunReleasingOrphanAsync(() => _tag.WriteAsync(cancellationToken)); + + /// + /// The writes the libplctag runtime refuses before the wire, or : any address whose + /// libplctag name had to change () + /// — file-number-less S:/O:/I: (incl. bit writes such as O:0/3, refused before the + /// parent-word read-modify-write) and the status bits rewritten to control word + bit (R6:0.DN, counter + /// UA). Before #659 libplctag could not create those tags at all, so no write to one ever reached the + /// controller; the read fix must not widen the write surface of the default transport. T/C status bits + /// libplctag parses itself (T4:0.DN, C5:0.CU…) keep their pre-existing behaviour — a masked bit + /// write (documented hazard in docs/drivers/AbLegacy.md). + /// + /// The tag's create parameters. + /// The refusal reason, or . + internal static string? WriteRefusal(AbLegacyTagCreateParams p) => + LibplctagLegacyName.IsTranslated(p.TagName) + ? $"Writes to '{p.TagName}' are not supported on the libplctag transport (its name is translated for libplctag; reads only)." + : null; /// /// Runs a libplctag operation; when it fails and leaves the tag uninitialised, destroys the native tag it @@ -233,4 +261,7 @@ internal sealed class LibplctagLegacyTagFactory : IAbLegacyTagFactory /// public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams) => new LibplctagLegacyTagRuntime(createParams); + + /// + public string? WriteRefusal(AbLegacyTagCreateParams createParams) => LibplctagLegacyTagRuntime.WriteRefusal(createParams); } 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), + ]; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagTranslatedWriteRefusalTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagTranslatedWriteRefusalTests.cs new file mode 100644 index 00000000..23099c42 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagTranslatedWriteRefusalTests.cs @@ -0,0 +1,158 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// #659 review rulings: the libplctag read translation must not widen the WRITE surface of the default transport. +/// Before it, libplctag could not create a tag for S:/O:/I: (no file number) or for an R/UA +/// status bit, so no write to one ever reached the controller; now that the translated names parse, a write would +/// drive an output, a status-file word or (masked) a control word. Every write whose address the translation +/// changes is refused BadNotSupported before any runtime is created, health unchanged. T/C status bits +/// libplctag itself parses keep their pre-existing (unrefused) behaviour. +/// +[Trait("Category", "Unit")] +public sealed class LibplctagTranslatedWriteRefusalTests +{ + [Theory] + [InlineData("R6:0.EN", true)] + [InlineData("R6:0.EU", true)] + [InlineData("R6:0.DN", true)] + [InlineData("R6:0.EM", true)] + [InlineData("R6:0.ER", true)] + [InlineData("R6:0.UL", true)] + [InlineData("R6:0.IN", true)] + [InlineData("R6:0.FD", true)] + [InlineData("C5:0.UA", true)] + [InlineData("T4:0.DN", false)] // libplctag parses it natively: pre-existing behaviour, out of scope + [InlineData("C5:0.CU", false)] + [InlineData("C5:0.DN", false)] + [InlineData("R6:0.LEN", false)] + [InlineData("R6:0.POS", false)] + [InlineData("S:0", true)] // file number supplied by the translation (S2:0) + [InlineData("S:1/5", true)] + [InlineData("S:57", true)] + [InlineData("O:0", true)] + [InlineData("O:0/3", true)] + [InlineData("I:0", true)] + [InlineData("I:0/0", true)] + [InlineData("N7:0", false)] + [InlineData("N7:0/3", false)] + [InlineData("F8:0", false)] + [InlineData("ST14:10", false)] + [InlineData("T4:0.PRE", false)] + [InlineData("B3:0/4", false)] + [InlineData("R6:0.FOO", false)] + [InlineData("garbage", false)] + public void The_libplctag_refusal_covers_exactly_the_translated_addresses(string tag, bool refused) + { + var p = new AbLegacyTagCreateParams("h", 44818, "", "micrologix", tag, TimeSpan.FromSeconds(1)); + (LibplctagLegacyTagRuntime.WriteRefusal(p) is not null).ShouldBe(refused); + (new LibplctagLegacyTagFactory().WriteRefusal(p) is not null).ShouldBe(refused); + LibplctagLegacyName.IsTranslated(tag).ShouldBe(refused); + } + + /// Defence in depth: a runtime for a rewritten status bit refuses the write itself, without touching the + /// network (the tag was never initialised, and the gateway is unroutable). + [Fact] + public async Task The_runtime_refuses_a_rewritten_status_bit_write_before_the_wire() + { + using var rt = new LibplctagLegacyTagRuntime(new AbLegacyTagCreateParams( + "192.0.2.1", 44818, "", "micrologix", "R6:0.DN", TimeSpan.FromSeconds(1))); + var ex = await Should.ThrowAsync(() => rt.WriteAsync(TestContext.Current.CancellationToken)); + ex.Status.ShouldBe(AbStatus.Unsupported); + } + + /// The production libplctag refusal predicate behind a counting factory, so the test sees whether the + /// driver created (and would therefore have initialised and written through) a runtime. + private sealed class CountingLibplctagRefusalFactory : IAbLegacyTagFactory + { + public FakeAbLegacyTagFactory Inner { get; } = new(); + + public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p) => Inner.Create(p); + + public string? WriteRefusal(AbLegacyTagCreateParams p) => new LibplctagLegacyTagFactory().WriteRefusal(p); + } + +#pragma warning disable OTOPCUA0001 // direct driver calls pin the driver's own status mapping (no resilience wrapper) + private static async Task<(AbLegacyDriver Driver, CountingLibplctagRefusalFactory Factory)> DriverAsync( + string address, AbLegacyDataType type = AbLegacyDataType.Bit) + { + const string host = "ab://10.0.0.5/"; + var factory = new CountingLibplctagRefusalFactory(); + var driver = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.MicroLogix)], + RawTags = AbLegacyRawTags.Entries([new AbLegacyTagDefinition("Bit", host, address, type, Writable: true)]), + Timeout = TimeSpan.FromSeconds(1), + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "drv-libplctag-statusbit", factory); + await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); + return (driver, factory); + } + + /// Includes bit writes to O/I/S (O:0/3): the refusal runs ahead of the bit read-modify-write, so + /// not even the parent-word runtime (O:0) is created — nothing is read or written. + [Theory] + [InlineData("R6:0.DN", AbLegacyDataType.Bit, true)] + [InlineData("R6:0.EN", AbLegacyDataType.Bit, true)] + [InlineData("C5:0.UA", AbLegacyDataType.Bit, true)] + [InlineData("O:0/3", AbLegacyDataType.Bit, true)] + [InlineData("I:0/0", AbLegacyDataType.Bit, true)] + [InlineData("S:1/5", AbLegacyDataType.Bit, true)] + [InlineData("O:0", AbLegacyDataType.Int, (short)1)] + [InlineData("S:1", AbLegacyDataType.Int, (short)1)] + public async Task A_translated_address_write_is_BadNotSupported_with_no_runtime_and_health_unchanged( + string address, AbLegacyDataType type, object value) + { + var ct = TestContext.Current.CancellationToken; + var (driver, factory) = await DriverAsync(address, type); + var health = driver.GetHealth(); + + (await driver.WriteAsync([new WriteRequest("Bit", value)], ct)).Single().StatusCode + .ShouldBe(AbLegacyStatusMapper.BadNotSupported); + + factory.Inner.Tags.ShouldBeEmpty(); + driver.GetHealth().ShouldBe(health); + + // Reads stay translated and allowed: the same tag reads through a runtime. + (await driver.ReadAsync(["Bit"], ct)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + factory.Inner.Tags.Keys.ShouldContain(address); + await driver.ShutdownAsync(ct); + } + + /// Positive control for the refusal's scope: a T/C status bit libplctag parses itself keeps its + /// pre-existing behaviour — the write reaches a runtime (documented hazard, out of scope for #659). + [Fact] + public async Task A_libplctag_native_timer_status_bit_write_still_reaches_a_runtime() + { + var ct = TestContext.Current.CancellationToken; + var (driver, factory) = await DriverAsync("T4:0.DN"); + + (await driver.WriteAsync([new WriteRequest("Bit", true)], ct)).Single().StatusCode + .ShouldBe(AbLegacyStatusMapper.Good); + + factory.Inner.Tags["T4:0.DN"].WriteCount.ShouldBe(1); + await driver.ShutdownAsync(ct); + } + + /// Positive control: an untranslated bit write (B3:0/4) still runs the parent-word + /// read-modify-write, so the refusal is not swallowing bit writes in general. + [Fact] + public async Task An_untranslated_bit_write_still_runs_the_parent_word_read_modify_write() + { + var ct = TestContext.Current.CancellationToken; + var (driver, factory) = await DriverAsync("B3:0/4"); + factory.Inner.Customise = p => new FakeAbLegacyTag(p) { Value = (short)0 }; + + (await driver.WriteAsync([new WriteRequest("Bit", true)], ct)).Single().StatusCode + .ShouldBe(AbLegacyStatusMapper.Good); + + factory.Inner.Tags["B3:0"].WriteCount.ShouldBe(1); + await driver.ShutdownAsync(ct); + } +#pragma warning restore OTOPCUA0001 +}