Merge i659 (#659 libplctag S/O/I + control status-bit reads on real MicroLogix; translated writes refused)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -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`).
|
||||
|
||||
|
||||
@@ -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/<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 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:
|
||||
|
||||
|
||||
@@ -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/<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`).
|
||||
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <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) => TranslateCore(canonical).Name;
|
||||
|
||||
/// <summary>
|
||||
/// True when <see cref="Translate"/> changes <paramref name="canonical"/>: a file number supplied for
|
||||
/// <c>S:</c>/<c>O:</c>/<c>I:</c> (incl. bit forms such as <c>O:0/3</c>) or a status bit rewritten to control word +
|
||||
/// bit (<c>R6:0.DN</c> → <c>R6:0.CON/13</c>; R <c>EN/EU/DN/EM/ER/UL/IN/FD</c>, counter <c>UA</c>). A WRITE to such an
|
||||
/// address is refused (<see cref="LibplctagLegacyTagRuntime.WriteRefusal"/>): 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.
|
||||
/// </summary>
|
||||
/// <param name="canonical">The canonical address.</param>
|
||||
/// <returns><see langword="true"/> when the libplctag name differs from the canonical one.</returns>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,17 @@ 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
|
||||
{
|
||||
private readonly Tag _tag;
|
||||
|
||||
/// <summary>Why a write to this tag is refused before the wire (<see cref="WriteRefusal"/>), else null.</summary>
|
||||
private readonly string? _writeRefusal;
|
||||
|
||||
/// <summary>
|
||||
/// The status the native tag held when a failed initialisation destroyed it (see
|
||||
/// <see cref="LibplctagOrphanRelease"/>); 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);
|
||||
}
|
||||
|
||||
/// <summary>Builds the (uninitialized) libplctag tag for <paramref name="p"/>.</summary>
|
||||
@@ -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));
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Throws the <see cref="WriteRefusal"/> refusal (Unsupported) before anything is sent — defence in depth;
|
||||
/// the driver asks the factory first and never creates a runtime for a refused write.</remarks>
|
||||
public Task WriteAsync(CancellationToken cancellationToken) =>
|
||||
RunReleasingOrphanAsync(() => _tag.WriteAsync(cancellationToken));
|
||||
_writeRefusal is { } refusal
|
||||
? Task.FromException(new AbTransportException(AbStatus.Unsupported, refusal))
|
||||
: RunReleasingOrphanAsync(() => _tag.WriteAsync(cancellationToken));
|
||||
|
||||
/// <summary>
|
||||
/// The writes the libplctag runtime refuses before the wire, or <see langword="null"/>: any address whose
|
||||
/// libplctag name <see cref="LibplctagLegacyName"/> had to change (<see cref="LibplctagLegacyName.IsTranslated"/>)
|
||||
/// — file-number-less <c>S:</c>/<c>O:</c>/<c>I:</c> (incl. bit writes such as <c>O:0/3</c>, refused before the
|
||||
/// parent-word read-modify-write) and the status bits rewritten to control word + bit (<c>R6:0.DN</c>, counter
|
||||
/// <c>UA</c>). 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 (<c>T4:0.DN</c>, <c>C5:0.CU</c>…) keep their pre-existing behaviour — a masked bit
|
||||
/// write (documented hazard in <c>docs/drivers/AbLegacy.md</c>).
|
||||
/// </summary>
|
||||
/// <param name="p">The tag's create parameters.</param>
|
||||
/// <returns>The refusal reason, or <see langword="null"/>.</returns>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <inheritdoc />
|
||||
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams) =>
|
||||
new LibplctagLegacyTagRuntime(createParams);
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? WriteRefusal(AbLegacyTagCreateParams createParams) => LibplctagLegacyTagRuntime.WriteRefusal(createParams);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
+158
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// #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 <c>S:</c>/<c>O:</c>/<c>I:</c> (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 <c>BadNotSupported</c> before any runtime is created, health unchanged. T/C status bits
|
||||
/// libplctag itself parses keep their pre-existing (unrefused) behaviour.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
[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<AbTransportException>(() => rt.WriteAsync(TestContext.Current.CancellationToken));
|
||||
ex.Status.ShouldBe(AbStatus.Unsupported);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Includes bit writes to O/I/S (<c>O:0/3</c>): the refusal runs ahead of the bit read-modify-write, so
|
||||
/// not even the parent-word runtime (<c>O:0</c>) is created — nothing is read or written.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Positive control: an untranslated bit write (<c>B3:0/4</c>) still runs the parent-word
|
||||
/// read-modify-write, so the refusal is not swallowing bit writes in general.</summary>
|
||||
[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
|
||||
}
|
||||
Reference in New Issue
Block a user