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:
@@ -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`).
|
||||
|
||||
|
||||
@@ -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<f>:<e>.CON/<bit>`
|
||||
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/<bit>` (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:
|
||||
|
||||
|
||||
@@ -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/<bit>`. 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`).
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Translates a canonical AbLegacy address (<see cref="AbLegacyAddress.ToLibplctagName"/> — what operators
|
||||
/// author and the native transport parses) into the <c>name=</c> form libplctag's PCCC parser accepts, so both
|
||||
/// transports address the same file type / file number / element / sub-element / bit (Gitea #659).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>libplctag 2.6.0 (<c>libplctag.NativeImport</c> 1.0.41, bundled by the <c>libplctag</c> 1.5.2 package),
|
||||
/// <c>src/protocols/ab/pccc.c</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:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>No implicit file number.</b> <c>parse_pccc_file_num</c> requires a digit after the file letter,
|
||||
/// and <c>parse_pccc_file_type</c> only recognises <c>S</c> as the status file when a digit follows it. So
|
||||
/// <c>S:0</c>, <c>O:0/3</c>, <c>I:0/0</c> fail to parse. They are emitted with the PCCC default file numbers
|
||||
/// the native encoder uses (<see cref="PcccFileType.ImplicitFileNumber"/>): <c>O0</c>, <c>I1</c>, <c>S2</c>
|
||||
/// — the same numbers on SLC 500, MicroLogix and PLC-5.</item>
|
||||
/// <item><b>Control status bits are missing from its sub-element table.</b> <c>sub_element_lookup</c> lists
|
||||
/// only <c>con</c>/<c>len</c>/<c>pos</c> for R files (and no <c>ua</c> for counters), and a bare
|
||||
/// <c>R6:0/13</c> is refused because <c>parse_pccc_bit_num</c> allows a bit only on a 2-byte element. A
|
||||
/// status bit libplctag does not know is therefore emitted as control word + bit, <c>R6:0.CON/13</c>, with
|
||||
/// the bit number taken from the native transport's own table
|
||||
/// (<see cref="PcccAddress.WithSubElementName"/>) so the two transports cannot disagree.</item>
|
||||
/// </list>
|
||||
/// <para>Everything else passes through unchanged — including names libplctag already reads correctly
|
||||
/// (<c>T4:0.DN</c>, <c>C5:0.ACC</c>, <c>R6:0.LEN</c>), and names this translator cannot parse (function files,
|
||||
/// indirect forms, unknown sub-elements), which keep failing in libplctag exactly as before.</para>
|
||||
/// </remarks>
|
||||
internal static class LibplctagLegacyName
|
||||
{
|
||||
/// <summary>The sub-element mnemonics libplctag 2.6.0's <c>sub_element_lookup</c> table knows, per file letter.</summary>
|
||||
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,
|
||||
};
|
||||
|
||||
/// <summary>Returns the libplctag tag name for a canonical AbLegacy address.</summary>
|
||||
/// <param name="canonical">The canonical (decimal-normalised) address, e.g. <c>S:0</c> or <c>R6:0.DN</c>.</param>
|
||||
/// <returns>The libplctag <c>name=</c> value, e.g. <c>S2:0</c> or <c>R6:0.CON/13</c>.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
/// <summary>
|
||||
/// Default libplctag-backed <see cref="IAbLegacyTagRuntime"/>. Uses <c>ab_pccc</c> 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 <see cref="LibplctagLegacyName"/> has put the canonical
|
||||
/// address in the form its parser accepts; our wrapper then forwards the atomic type to the
|
||||
/// right Get/Set call.
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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