Compare commits

...

32 Commits

Author SHA1 Message Date
Joseph Doherty b25ee10a14 test(abcip): real-press pass 3 with packet capture — 17/17, zero write services, 4 sequential sessions (Z-35162)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 22:39:33 -04:00
Joseph Doherty 8e2dbc6721 merge: master (TwinCAT native array subscriptions) into feat/native-ab-transport
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:51:12 -04:00
Joseph Doherty f73c2db599 docs(ab): final review r2 m4/m5 — AbCipTransport doc names Native as default; AbLegacy array-write deferral scoped to libplctag
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:50:57 -04:00
Joseph Doherty ee3ffa90ef docs: final review — CLAUDE.md AB transport section (I1/I2/I3/I4)
AbCip: node-level Drivers:AbCip:DefaultTransport and the working pin
recipe. AbLegacy: default libplctag, Native opt-in, exact wire evidence.
Type-mismatch and Unsupported are refusals that never evict or degrade.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:41:18 -04:00
Joseph Doherty cc3bea8370 fix(ab): final review M2 — a retyped libplctag handle field degrades to keep-alive
HandleOf cast the reflected nativeTagHandle with (int) and was called
inside the runtimes' catch blocks, so a libplctag bump that retyped the
field would replace every create failure with an InvalidCastException.
The field is now accepted only if it is still an int (otherwise
CanReadHandles is false and orphans are rooted), and HandleOf never
throws. Both projects pin the field type explicitly.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:41:03 -04:00
Joseph Doherty 352ea449e9 fix(abcip): final review I4 — a type mismatch is a refusal, not a comms fault
A native TypeMismatch (read status, write gate, or CIP 0xFF/0x2107) used
to evict the runtime and degrade health on every poll; for a sole lease
holder that meant Forward Close + Forward Open per poll, and an all-
mismatched driver tripped the comms-loss watchdog. It now reports
BadTypeMismatch, keeps the handle, leaves health alone and warns once per
tag per instance — reads, whole-UDT reads and writes, plus the libplctag
width check. Tests (red against the pre-fix driver): single create over N
polls, unchanged health, one warning, and one Forward Open / zero Forward
Close over the real native runtime against FakeCipTarget. AbLegacy has no
TypeMismatch path (its native runtime has no type check).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:40:04 -04:00
Joseph Doherty 2cea1a0687 fix(ab-transport): final review I2 — wire DeviceSessionPool.Shared to the host logger
ConfigureSharedLogger had no production caller, so the pool's only
diagnostics (first-wins options conflicts, scheduler/close failures)
went to NullLogger. DriverFactoryBootstrap now configures it from the
driver registry factory and from the AbCip/AbLegacy probe factories
(admin-only nodes reach the pool only through Test Connect). Wiring tests
reset the pool to a null logger first, so removing the call goes red
(verified).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:36:37 -04:00
Joseph Doherty b75e013d0a feat(abcip): final review I1 — node-level Drivers:AbCip:DefaultTransport
The old AdminUI cannot author the per-driver transport key (#576), so the
'pin before upgrading' recipe had no working path. A node-level appsetting
(env Drivers__AbCip__DefaultTransport) now supplies the default for AbCip
drivers with no key: explicit key > node default > compiled Native. Bound
and validated unconditionally (bad value fails host start); the driver
factory and the Test Connect probe both read it so they agree. AbCip.md
upgrade recipe rewritten around the key; Configuration.md documents it.
Also documents first-wins for AllowPacking/probe and host-alias throttles
(final review M6) and moves the status-bit note out of AbCip.md (M5).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:34:48 -04:00
Joseph Doherty b66972b6f5 fix(ablegacy): final review I3 — default reverts to libplctag; Native is opt-in
On a wire the native PCCC path is proven only for N-file reads/writes and
one F8 read (ab_server). An absent transport key now resolves to Libplctag
for every family; explicit Native still wins and still warns once per
instance on PLC-5/LogixPccc. AdminUI dropdown, CLI help, evidence claims
(PcccFileType, AbLegacy.md wire-evidence table, release note) corrected:
S:0 (default probe) and B/T/C/R/S/L/ST/A/O/I are unverified natively.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:34:04 -04:00
Joseph Doherty a9fc933e3b docs(plans): native AB transport tasks.json status sync
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:14:44 -04:00
Joseph Doherty 39ae286cd7 fix(ab): a refused operation is BadNotSupported, not a comms fault (Task 22 review r1; closes #571)
Native is the default, so a locally refused write must not look like a comms fault.

- AbLegacy: an AbTransportException with AbStatus.Unsupported (status-bit sub-element writes, PLC-5
  non-N reads, all PLC-5 writes; libplctag's ErrorUnsupported maps here too) now returns
  BadNotSupported on both the read and the write path (incl. the bit-RMW path). The tag handle is
  not evicted and driver health is not touched. A native runtime whose init read is refused locally
  is CACHED rather than disposed, so a poll of a refused tag does not re-create (and re-lease the
  pooled connection) every cycle.
- AbCip: the same mapping on the write path and both read paths (single + whole-UDT).
- Uses the existing named BadNotSupported constants (StatusCodeParityTests guard).
- Tests (falsified by disabling the mapping, and separately the keep-on-refusal): status-bit
  writes and PLC-5 read/write through the driver over the native runtime — BadNotSupported, zero
  wire requests, one create, health unchanged; AbCip refused read/write via a refusing fake plus a
  control that a non-refusal failure still evicts and degrades.
- Docs/CLAUDE.md: BadNotSupported; #571 known-gap wording removed.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:12:08 -04:00
Joseph Doherty 1806af54f0 fix(ab): Task 22 review r1 — release-note timing and AbLegacy regressions, probe E2E on both transports
- I1: the upgrade restart itself moves every no-key AbCip/AbLegacy driver to Native (every process
  start constructs drivers, incl. boot-from-cache). Docs + CLAUDE.md now say so and tell operators
  to set "transport": "Libplctag" BEFORE upgrading (older builds ignore the unknown key).
- I2: AbLegacy release note leads with the status-bit write refusal (T4:0.DN, C5:0.CU, R6:0.EN, ...)
  on defaulted SLC/MicroLogix drivers and the fallback. The real client status is
  BadCommunicationError, not BadNotSupported (the driver's write catch-all maps the runtime's
  AbTransportException like any transport failure, as #571 does for PLC-5), with handle eviction
  and Degraded health; pinned by a driver-level test (no 0xAA write on the wire, with a positive
  control). ST string byte order stated as unverified. Also corrects the docs' "T4:0/DN" examples:
  the driver's address grammar is "T4:0.DN" ("/DN" is a malformed address).
- I3: DriverProbeHandshakeE2eTests' two AbCip probe tests are a [Theory] over Native and
  Libplctag. Run against ab_server controllogix: all 4 rows pass with identical exact messages.
- M1: documented (docs + resolver XML doc): the AdminUI merges only the probed device, so a
  mixed-family defaulted AbLegacy driver can probe Native while running Libplctag.
- M2: factory ParseEnum rejects undefined integer strings ("7") via Enum.IsDefined (both drivers).
- M3: AbCip driver-level bad value reads "AB CIP config in '<id>' has unknown Transport ..."; the
  named constant is gone (tagName: null now means driver-level).
- M4: the explicit-Native limited-family Warning is once per instance, not per (re)init.
- Nit: EffectiveTransport docs note that injected factories bypass it.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:06:40 -04:00
Joseph Doherty ed0881d672 feat(ab): native managed transport is the default for AbCip and AbLegacy; libplctag remains as fallback
Task 22 (spec section 8 P5), with the controller rulings that exclude PLC-5 and LogixPccc.

- Transport is nullable on both option records; null (key absent) means "use the default" and is
  never serialized. Factory DTO parse: absent -> null; a bad value still fails via ParseEnum.
- One resolver per driver (AbCipTransportResolver / AbLegacyTransportResolver), used by the driver
  constructor, the Test Connect probe and (through the driver) the CLI.
  AbCip: null -> Native. AbLegacy: null -> Native unless any device is Plc5 or LogixPccc, then the
  whole driver resolves to Libplctag. Explicit values always win; explicit Native with a
  Plc5/LogixPccc device logs one Warning at init.
- Init log shows configured vs effective, e.g. transport=Native (default).
- AdminUI forms: "Default (...)" / "Native (managed)" / "libplctag (fallback)". Default omits the
  key on save (reverses Task 19's explicit re-emit, which would have pinned every edited driver).
- CLIs: --transport default|native|libplctag, default "default" via the resolver, so
  --plc-type Plc5 with no --transport runs libplctag.
- Tests pinned to Libplctag where a default-path runtime was created: found by instrumenting the
  native factories with the current xUnit test name (one unit test, AbLegacyReinitConfigAdoption;
  plus the two fixture-gated AbCip probe E2E tests whose exact messages were proven on libplctag).
  TransportMatrix and the differential harness already pass explicit transports.
- Switch/resolver/CLI/form tests are falsified by mutating the resolver (default -> Libplctag,
  exclusion removed, AbLegacy default -> Libplctag): each mutation turns them red.
- Replaces the inline role-name literal in AbCipDriverFactoryExtensions' ParseEnum call with a named
  constant; RoleAuthorityGuardTests was already red on the base commit for that line.
- Docs: AbCip.md / AbLegacy.md default + release note; CLAUDE.md AB section.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 20:49:33 -04:00
Joseph Doherty 17e1170e61 docs(plans): native AB transport tasks.json status sync
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 20:26:00 -04:00
Joseph Doherty 6a22d28098 test(abcip): real-press read-only gate — native vs libplctag differential and latency (Z-35162)
Pass 2 (2026-10-08 00:21Z, df776170): 17/17. Browse 3162=3162 tags, 20 UDT
templates identical, 200 tags read with 0 unexpected divergences (6 live
values compared structurally + every still element), 1 Forward Open and 1
acked Forward Close per native phase. Latency native p95 105.8 ms vs
libplctag 139.5 ms (limit 153.4) PASS. Pass 1 failures and their fixes are
recorded in the gate-results doc; pass 2 had no independent pcap.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 20:25:32 -04:00
Joseph Doherty df77617051 docs(abcip): Large Forward Open refusal targets and ConnectionSize honouring (T21 review r1 M6)
The 5069-L3x supports the Large open, as the press proved. The narrow/refusing case is a pre-v20 1769-L3x or a bridge. AbCipDeviceOptions.ConnectionSize: the native transport honours it on the wire; libplctag ignores it.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 20:16:34 -04:00
Joseph Doherty 6ae6287360 fix(ab): orphan release clears the wrapper handle; rooting fallback documented and pinned (T21 review r1 M1/M2)
M2: after plc_tag_destroy, the release writes 0 back to the wrapper's nativeTagHandle, so a repeat release is a no-op instead of touching a destroyed id. The runtimes keep the destroyed handle for tests and diagnostics.

M1: the rooting fallback is now described accurately, as an unbounded leak (one native tag per failed create), not a bounded one. A test seam (ForceRootingForTest) pins that the fallback roots the tag and destroys nothing.

Both pins are red with their line removed. The orphan tests share a non-parallel collection, because the counters and the seam are process-wide.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 20:16:33 -04:00
Joseph Doherty 43b81b6643 test(abcip): live tags keep a per-element value check; close barrier and GC/RTT attribution (T21 review r1 I1/M9/M8/M4)
I1: a live verdict no longer waives the value check. Only elements that moved between the two libplctag reads are excused.
- An element equal in both libplctag reads must also be equal through native; otherwise **NO: stable element i**.
- A moved element must not read NaN or infinity on native unless libplctag saw it too; otherwise **NO: non-finite element i**.
- Live tags log their full raw bytes and every element, and the summary reports live tags as N of the total.
- The offline pin adds the pass-1 Real[5] shape: a stable element shifted on native fails. It is red with the element check disabled.

M9: after each native phase (differential and latency), wait for the session close. With AB_DIFF_COUNT_OPENS=1, assert exactly one Forward Close was sent, using the new CipConnection counters; acked is logged.

M8: each latency pass logs its GC collection deltas (gen0/1/2) for both transports, plus native per-exchange round-trip min/mean/max.

M4: the latency test also asserts that no libplctag orphan had to be rooted.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 20:16:33 -04:00
Joseph Doherty c90b1a8214 feat(ab-transport): Forward Close and per-exchange round-trip gate diagnostics (T21 review r1 M8/M9)
CipConnection gains two process-wide gate-diagnostic counters next to ForwardOpenCount: ForwardClosesSent (incremented as the request goes on the wire) and ForwardClosesAcked (incremented when the reply arrives within the 1 s budget).

It also records round-trip statistics for completed exchanges: ResetExchangeStats() clears them and ReadExchangeStats() returns count, min, max and mean. The cost is two Stopwatch reads and a few interlocked operations per request. No behaviour changes.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 20:16:33 -04:00
Joseph Doherty 82a0c298ce test(abcip): read-parity volatility probe; live tags compared structurally (T21 press pass 1)
In T21 press pass 1, two REAL tags diverged only because their live values moved
between the sequential libplctag and native phases (WW_M4ActualTemp 384.887 ->
385.138).

The libplctag phase now reads every selected tag a second time, paced, before
native starts (TransportObservations.ProbeReads). A tag whose two libplctag reads
both succeed and differ is classed live. For a live tag, parity still requires:
- the same status (and the native-TypeMismatch rule);
- no decode error;
- the same raw length;
- the same CIP type code when both sides expose one (native records it now;
  libplctag.NET 1.5.2 does not);
- the same decoded shape.
Value equality is not required. Live tags get their own list and a "libplctag
probe" table column, and are counted as neither expected nor unexpected
divergences.

A tag that reads stable on libplctag but differs on native is still an unexpected
divergence, and a missing or failed probe never makes a tag live. The offline pin
A_live_value_is_compared_structurally_and_a_stable_one_by_value goes red with the
live branch disabled.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 19:50:41 -04:00
Joseph Doherty 6cd6876229 fix(ab): destroy the native libplctag tag a failed initialisation leaves behind (T21 press pass 1 crash)
The T21 press pass 1 test host died at exit with "A callback was made on a garbage
collected delegate of type ...plctag+callback_func_ex::Invoke". The cause is in
libplctag.NET 1.5.2. When a tag's create fails, for example with NotFound,
InitializeAsync throws with the callback still registered. Dispose() destroys a tag
only if it is initialised, so the native tag outlives its wrapper. The GC then frees
the delegate, and libplctag's atexit plc_tag_shutdown raises DESTROYED into it.
Reproduced against ab_server: the production LibplctagTagRuntime with a NotFound tag
exits 134 with that exact message, and exits 0 with this fix.

A timed-out create unregisters its callback but never destroys the handle either,
so that native tag keeps reconnecting for the life of the process.

The fix adds LibplctagOrphanRelease (AbCip and AbLegacy). After a failed
initialise/read/write that leaves the tag uninitialised, it reads the handle through
the pinned wrapper's private fields, unregisters the callback and destroys the tag.
If those fields cannot be read, it roots the tag for the life of the process
instead. It is applied in both tag runtimes, the symbol-listing reader and the
template reader. GetStatus keeps the status the tag held before it was destroyed.

Pins: offline refused-connection tests in AbCip.Tests and AbLegacy.Tests, a live
NotFound test on ab_server, and a reflection-shape pin. All go red with the release
disabled. The differential harness also asserts that no orphan had to be rooted.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 19:49:03 -04:00
Joseph Doherty c306c6f674 fix(abcip): CompactLogix native default connection size matches libplctag's Large Forward Open (found by T21 latency harness) 2026-10-07 19:34:30 -04:00
Joseph Doherty 2dc55ecb8f test(abcip): read-only poll-latency comparison harness for the T21 press gate (proven on ab_server) 2026-10-07 19:26:23 -04:00
Joseph Doherty 3ea72f29dd docs(plans): native AB transport tasks.json status sync 2026-10-07 19:12:59 -04:00
Joseph Doherty e066f89290 docs(ab): note the micro800 libplctag re-run after the empty-path fix 2026-10-07 19:12:59 -04:00
Joseph Doherty c7f7c940a8 docs(ab): native transport, Transport switch, known gaps and the type-check change 2026-10-07 19:12:32 -04:00
Joseph Doherty 9a576599e1 docs(plans): native AB transport tasks.json status sync 2026-10-07 19:11:03 -04:00
Joseph Doherty f3e92fec27 Merge TwinCAT native array subscriptions (live-validated on the Elma PLCs)
v2-ci / build (push) Successful in 4m39s
v2-ci / unit-tests (push) Failing after 15m16s
2026-10-07 15:08:17 -04:00
Joseph Doherty 1982af8155 docs(twincat): native array notifications, sub-range / size-cap rules, live TC2 gate record
Removes the 'use UseNativeNotifications=false for arrays' workaround and
documents the raw-bytes array model shared by poll and notification: element
checks, STRING arrays, multi-dim flattening, 0-based publication, leading
sub-range for a shorter arrayLength, refusals (struct/UDT arrays, too long,
scalar over array), the 128 KiB native cap (BadOutOfRange), the bit-path fix,
and the 2026-10-07 read-only live gate on Z33856 / Z33733.
2026-10-07 14:56:03 -04:00
Joseph Doherty 313946e6ea fix(twincat): refuse a scalar tag over a PLC array on the read path too
Live on Z33856 a UInt tag over .ARRFAULTCODE (ARRAY [0..12] OF UINT) made the
Beckhoff read throw - BadCommunicationError and a Degraded driver - while the
native path reported BadTypeMismatch. CheckRead now refuses a scalar declared
over an array (by category, so an alias of an array type is caught), pointing
at the array-tag / element-path alternatives.
2026-10-07 14:50:39 -04:00
Joseph Doherty 1e68121b17 fix(twincat): register a bit-indexed BOOL notification on its parent word (live TC2 gate)
A native notification for MAIN.Flags.3 / .ADSSTATE_RUN.0 was registered on the
literal WORD.N path, which ADS has no symbol for: live on the TC 2.11 Elma
cleaner Z33856 it failed with DeviceSymbolNotFound, and that registration
error tore down the whole subscribe call. The read path already reads the
parent word; the notification now does the same - it registers the parent as
raw bytes at its resolved width and reads the bit (TwinCATBitAccess), refusing
a bit outside the parent or a non-integer parent with BadTypeMismatch.
2026-10-07 14:50:39 -04:00
Joseph Doherty dcc35f3481 feat(twincat): native ADS notifications for array tags, decoded from raw bytes
An array tag subscribed natively used to register only its declared scalar
(publishing element 0 at Good) and was then refused wholesale with
BadTypeMismatch. The driver now hands the authored ArrayLength to the client,
which registers the whole array as raw bytes and decodes every notification.

- TwinCATTypeGuard.CheckArray plans the layout from the RESOLVED array type:
  element checked like a scalar notification (width / float kind / string
  kind); STRING(n)/WSTRING(n) elements stride n+1 / 2(n+1); multi-dim arrays
  flattened in PLC memory order; an authored ArrayLength shorter than the PLC
  array reads the leading sub-range, a longer one is refused; struct / pointer
  / nested-array elements, non-array symbols and VAR_IN_OUT refs are refused.
- CheckNotificationSize caps a native array at 128 KiB (largest registration
  proven on a live TC 2.11 target); above it the tag is refused BadOutOfRange.
- TwinCATArrayCodec decodes the bytes into the element-typed CLR array.
- The poll read uses the same plan + codec instead of the Beckhoff typed array
  read, which failed live for most real arrays (exact CLR element type, rank-N
  for multi-dim, member arrays sliced by the under-reported TC2 symbol size).
- TwinCATNotificationRefusedException carries the refusal's status; the type
  mismatch derives from it and the driver isolates either per tag.
2026-10-07 14:45:35 -04:00
91 changed files with 5892 additions and 357 deletions
+32
View File
@@ -973,6 +973,38 @@ docker-dev. The rationale for every decision above is in
Dev/test LDAP is the **shared GLAuth** running on the Linux Docker host at `10.100.0.35:3893` (baseDN `dc=zb,dc=local`, plaintext/`Transport=None`). It is managed via `scadaproj/infra/glauth/` (source of truth + deploy runbook). Single bind account `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123`; all test users password `password`. The docker-dev compose binds this shared instance directly — `DevStubMode` is no longer used. The per-VM NSSM GLAuth at `C:\publish\glauth\` and the old base DNs `dc=lmxopcua,dc=local` / `dc=otopcua,dc=local` are obsolete. (The integration-test harness under `tests/.../Host.IntegrationTests/` runs its own ephemeral **GLAuth** container on port 3894 — seeded by `tests/.../Host.IntegrationTests/glauth/config.toml` with dev users alice/bob + the `cn=serviceaccount` bind account — for the opt-in `OTOPCUA_HARNESS_USE_LDAP=1` real-bind mode; distinct from the shared dev GLAuth on 3893. It replaced the retired `bitnami/openldap:2.6` in PR #451, 2026-07-15.)
## AB drivers: native transport (`Transport`)
AbCip and AbLegacy have a second, managed wire transport (`Driver.Ab.Transport`) beside libplctag, chosen by
the driver-config key `transport` = `Native` | `Libplctag` (string; takes effect when the driver is
**constructed** — every process start, or a deploy that recreates it, not `ReinitializeAsync`; AdminUI
"Transport" dropdown; CLIs `--transport`). "Default" is encoded by omitting the key; the AdminUI must never
re-emit a concrete default. The defaults differ per driver (final review, 2026-10-07):
- **AbCip: an absent key resolves to the node default, `Native` unless pinned.** Precedence (one place,
`AbCipTransportResolver`, shared by driver, probe and CLI): explicit per-driver key → node-level appsetting
**`Drivers:AbCip:DefaultTransport`** (`Native` | `Libplctag`, env `Drivers__AbCip__DefaultTransport`,
`AbCipDriverDefaultsOptions`; a bad value **fails host start**) → compiled `Native`. Bound unconditionally
because the driver factory AND the admin-node Test Connect probe both read it. ⚠️ So **the upgrade restart
alone moves no-key AbCip drivers to Native**; to stay on libplctag, set the node key — NOT a per-driver key
through the old AdminUI, whose typed form drops unknown keys (#576). Evidence: AbCip reads proven on a real
CompactLogix (Z-35162, read-only); AbCip writes are `ab_server`-only.
- **AbLegacy: an absent key resolves to `Libplctag` for every family; `Native` is opt-in** (no node key). On
a wire the native PCCC path is proven only for **N-file reads/writes and one F8 read**, all on `ab_server`;
`S:0` (the default probe), B/T/C/R/S/L/ST/A/O/I and the T/C/R sub-element offsets are encoded from the PCCC
table and **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`) and its ST byte order
is unverified.
Refusals are not comms faults: an Unsupported refusal (`BadNotSupported`) and an AbCip native **type
mismatch** (`BadTypeMismatch`, where libplctag returned Good garbage for a same-width mis-authored type) both
keep the tag handle, leave driver health alone (no `Degraded`, nothing for the comms-loss watchdog) and warn
once per tag — only genuine transport failures evict and degrade. `DeviceSessionPool.Shared` logs through the
host logger factory (wired in `DriverFactoryBootstrap`). libplctag is confined to `Libplctag*.cs` adapters by
`LibplctagConfinementGuardTests`; do not reference it elsewhere. See `docs/drivers/AbCip.md` §Transport,
`docs/drivers/AbLegacy.md` §Transport + §Wire evidence, `docs/Configuration.md` §`Drivers:AbCip`, and
`docs/plans/2026-10-07-native-ab-transport-gate-results.md`.
## Library Preferences
- **Logging**: Serilog with rolling daily file sink
+25
View File
@@ -445,6 +445,30 @@ for the enablement runbook (ApiKey handling, stop/start-together rule, tombstone
never-`sqlite3`-a-live-WAL-DB inspection rules) and the "pair-local config cache" section of
[`docs/Redundancy.md`](Redundancy.md) for what boot-from-cache does and does not cover.
### `Drivers:AbCip` (node-level AbCip defaults)
- **Purpose:** pin the wire transport an **AbCip driver with no `transport` key** runs on, for every such
driver on this node, without editing driver configs (final review I1 of the native AB transport). The
per-driver `DriverConfig` key `transport` still wins when present. The main use is an upgrade: the old
AdminUI cannot author the per-driver key (its typed form drops unknown keys, #576), so this is the way to
keep a fleet on libplctag across the upgrade restart. See
[`drivers/AbCip.md` § Transport](drivers/AbCip.md#transport-transport-option).
- **Options class:** `AbCipDriverDefaultsOptions` (`SectionName = "Drivers:AbCip"`) —
`src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/AbCipDriverDefaultsOptions.cs`. Bound and validated
unconditionally in `Program.cs` (`AddValidatedOptions` + `ValidateOnStart`), because both consumers need
it: the driver factory (driver role) and the AdminUI Test Connect probe (admin role), so the probe
exercises the stack the driver would run.
| Key | Type | Default | Meaning |
|---|---|---|---|
| `DefaultTransport` | string | `Native` | `Native` or `Libplctag`, case-insensitive. Blank/absent = `Native`. **Any other value fails host start** (`AbCipDriverDefaultsOptionsValidator`, naming the key), including numeric (`1`) and flags (`Native,Libplctag`) forms. Env form: `Drivers__AbCip__DefaultTransport`. |
Precedence for an AbCip driver: explicit `transport` in its `DriverConfig` → this key → compiled `Native`.
Like the per-driver key, it takes effect when a driver instance is **constructed** (process start, or a
deploy that recreates the driver), not on `ReinitializeAsync`. The init log line reads
`transport=Libplctag (node default)` when the pin is what selected the stack. There is **no AbLegacy
counterpart**: an AbLegacy driver with no key always runs libplctag (Native is opt-in per driver).
---
## Environment variables
@@ -458,6 +482,7 @@ All names are read in this repo's source via `Environment.GetEnvironmentVariable
| `OTOPCUA_ROLES` | `EffectiveRoles.Resolve` (`ZB.MOM.WW.OtOpcUa.Cluster`) — the single role authority both `Program.cs` and `AddOtOpcUaCluster` consume | Comma-separated cluster roles for the node (`admin`, `driver`, `dev`, `cluster-{ClusterId}`), read as the **fallback** only when `Cluster:Roles` is empty. Drives the conditional wiring, the per-role appsettings overlay, and the node's Akka member roles — all from the same resolved instance. See the note under `Cluster:Roles` above. |
| `OTOPCUA_CONFIG_CONNECTION` | `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/DesignTimeDbContextFactory.cs` (design-time / `dotnet ef` only) | Read at **design time** by `DesignTimeDbContextFactory.cs` for `dotnet ef` migrations. At **runtime** the server resolves the connection string from `ConnectionStrings:ConfigDb` (env form: `ConnectionStrings__ConfigDb`) via `configuration.GetConnectionString("ConfigDb")` in `ServiceCollectionExtensions.cs` — `OTOPCUA_CONFIG_CONNECTION` appears there only as a hint in an error message, not via `GetEnvironmentVariable`. No credential is embedded in source. |
| `ASPNETCORE_ENVIRONMENT` | ASP.NET host builder (framework) | Selects `appsettings.{Environment}.json` (e.g. `Development`). |
| `Drivers__AbCip__DefaultTransport` | configuration binder (`AbCipDriverDefaultsOptions`) | Env form of [`Drivers:AbCip:DefaultTransport`](#driversabcip-node-level-abcip-defaults): `Native` (default) or `Libplctag` for AbCip drivers with no `transport` key. A bad value fails host start. |
### Historian (`ServerHistorian__ApiKey`)
+1 -1
View File
@@ -595,7 +595,7 @@ unknown keys are preserved byte-stable on round-trip by all typed editors.
| **S7** | `ReadBytesAsync` block + per-element decode loop | Unit-proven (fixture down) |
| **AB CIP** | libplctag native array read (atomic + UDT member arrays) | Unit-proven (fixture down) |
| **AB Legacy** | PCCC multi-element file read via libplctag (cap 256 elements) | Unit-proven (fixture down) |
| **TwinCAT** | ADS native array symbol read | Unit-proven (fixture down) |
| **TwinCAT** | Raw-bytes array read + per-element decode (native notifications carry the whole array) | Live-verified on TwinCAT 2.11 (2026-10-07) |
Array writes, multi-dimensional arrays (ValueRank>1), and per-element historization are out of scope — see [Uns.md §Array tags](Uns.md#array-tags-1-d).
+1 -1
View File
@@ -310,7 +310,7 @@ Combined with historization (values are arrays — history of the whole array sn
| **S7** | Contiguous `ReadBytesAsync` block over the declared address span + per-element decode loop | Unit-proven (sim fixture down) |
| **AB CIP** | libplctag native array read (atomic and UDT member arrays) | Unit-proven (sim fixture down) |
| **AB Legacy** | PCCC multi-element file read via libplctag (cap 256 elements) | Unit-proven (sim fixture down) |
| **TwinCAT** | ADS native array symbol read against the declared `SymbolPath` | Unit-proven (sim fixture down) |
| **TwinCAT** | Raw-bytes read of the declared `SymbolPath` at the resolved array type's layout + per-element decode; native ADS notifications carry the whole array (same decode). Multi-dim PLC arrays publish flattened; a shorter `arrayLength` reads the leading sub-range | Live-verified 2026-10-07 on two TwinCAT 2.11 PLCs (see [TwinCAT.md §Array tags](drivers/TwinCAT.md#array-tags)) |
### Out of scope (named deferrals)
+123 -1
View File
@@ -76,12 +76,134 @@ the driver's `DriverConfig` JSON. Key fields:
- **`Devices`** — one `AbCipDeviceOptions` per PLC (`HostAddress`, `PlcFamily`, optional `DeviceName`, per-device `AllowPacking` / `ConnectionSize` overrides).
- **`Tags`** — pre-declared `AbCipTagDefinition` list; `Members` for UDT fan-out, `SafetyTag` for GuardLogix safety-partition tags.
- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeTagPath`.
- **`Transport`** — absent (default ⇒ `Native`), `Native`, or `Libplctag` (fallback); see [Transport](#transport-transport-option).
- **Discovery** — `EnableControllerBrowse` (`@tags` walk) and `EnableDeclarationOnlyUdtGrouping` (whole-UDT read fast path).
- **Alarms** — `EnableAlarmProjection` + `AlarmPollInterval` for the ALMD projection.
Full per-field descriptions live in `AbCipDriverOptions.cs`. The JSON skeleton is
reproduced in [docs/v2/driver-specs.md §3](../v2/driver-specs.md).
## Transport (`transport` option)
The driver has two interchangeable wire transports, selected by one `DriverConfig` key:
| Key | Values | Default | Serialization |
|---|---|---|---|
| `transport` | absent \| `Native` \| `Libplctag` | absent ⇒ the node's `Drivers:AbCip:DefaultTransport` (itself default `Native`) | string, case-insensitive on read, written as `"Libplctag"` / `"Native"`; "use the default" is encoded by OMITTING the key |
- **`Native`** (the default since Task 22) is a managed in-process EtherNet/IP + CIP client
(`Driver.Ab.Transport`).
- **`Libplctag`** (fallback) drives the libplctag native library, as before.
- An absent key means "use the default". It is resolved in one place, `AbCipTransportResolver`, which the
driver, the Test Connect probe and the CLI all go through. **Precedence:** an explicit per-driver key →
the node-level appsetting **`Drivers:AbCip:DefaultTransport`** (`Native` | `Libplctag`, env
`Drivers__AbCip__DefaultTransport`; a bad value fails host start; see
[Configuration.md](../Configuration.md#driversabcip-node-level-abcip-defaults)) → compiled `Native`. The
Test Connect probe uses the same node value, so it probes the stack the driver would run. The CLI has no
node config and uses compiled `Native`.
- A bad value fails like a bad `PlcFamily`; the Test Connect probe reports "Config JSON is invalid".
- A change takes effect when the driver instance is **constructed**: on every process start (so the upgrade
restart moves no-key drivers to Native — see the release note) and when a deploy that changes `DriverConfig`
does this (`DriverHostActor` respawns the child, #516). `ReinitializeAsync` alone keeps the transport
it was constructed with.
- AdminUI: the **Transport** dropdown in the advanced section of the AbCip driver form ("Default (node
setting; Native unless pinned)" / "Native (managed)" / "libplctag (fallback)"). **Default omits the key on save**, so a defaulted driver stays
defaulted; saving another field preserves an explicit value and preserves absence.
- The driver logs `transport=<effective> (default|configured)` at Information on every init, e.g.
`transport=Native (default)` or `transport=Libplctag (node default)`, including reconnect re-inits. AbCip
drivers now receive the host logger (they previously ran on a null logger, so none of their warnings
reached the node log); expect previously invisible warnings, e.g. a probe enabled with no probe tag path.
- CLI: `--transport default|native|libplctag` (default `default`, which goes through the same resolver).
- libplctag is confined to `Libplctag*.cs` adapters, enforced by a source-scan guard
(`LibplctagConfinementGuardTests`), so removing it later is mechanical.
### Release note — native is the default (Task 22)
- **The native managed transport is now the default.** A driver config with no `transport` key runs
`Native`; libplctag remains available as the fallback.
- ⚠️ **Client-visible:** native reports **`BadTypeMismatch`** for a mis-authored same-width type (for example a
tag authored `DINT` where the controller holds a `REAL`), where libplctag returned Good garbage. Correct the
authored type; see [Native transport behaviour](#native-transport-behaviour). It does not degrade driver
health or churn the connection; one Warning per tag names it.
- **To fall back** one driver, set `"transport": "Libplctag"` in its driver config (or pick "libplctag
(fallback)" in the AdminUI dropdown). To fall back a whole node, set `Drivers:AbCip:DefaultTransport` to
`Libplctag` (below).
- ⚠️ **When it takes effect: the upgrade restart itself.** Every process start constructs the drivers
(including boot-from-cache on a driver-only node), so **every driver with no `transport` key moves to the
node default — `Native` unless pinned — the moment the upgraded node starts**; no deploy is needed. After the
upgrade, a per-driver transport change takes effect when a deploy recreates the driver (not on
`ReinitializeAsync`).
- **To stay on libplctag across the upgrade (pin before upgrading):** set the node-level key on each node
you upgrade, in the same change that installs the new build — `appsettings.json`
`"Drivers": { "AbCip": { "DefaultTransport": "Libplctag" } }`, or the environment variable
`Drivers__AbCip__DefaultTransport=Libplctag` on the service. Older builds ignore the section, so it can be
staged ahead of the binaries. Every AbCip driver with no `transport` key then starts on libplctag; the init
log shows `transport=Libplctag (node default)`. Move to Native later per driver (explicit
`"transport": "Native"` in the new AdminUI) or for the node (remove the key / set `Native`, then restart).
⚠️ Do **not** rely on adding `"transport": "Libplctag"` to driver configs through the **old** AdminUI before
upgrading: its typed AbCip form has no Transport field and drops unknown keys on save (#576), and on a fused
`admin,driver` node the AdminUI and drivers upgrade together, so there is no window. The node key works on
every node shape, including fused nodes and driver-only nodes booting from cache.
- **Evidence, and its limits:** AbCip **reads** (plus browse and templates) were proven on a production
CompactLogix 5069-L320ERS2 (**Z-35162**, read-only, 2026-10-08; see
[gate results](../plans/2026-10-07-native-ab-transport-gate-results.md) §Real press). AbCip **writes** are
simulator-proven only (`ab_server`).
- **AbLegacy is different: its default stays libplctag** and Native is opt-in per driver; the native PCCC
path has wire evidence (on `ab_server`) only for N-file reads/writes and one F8 read (see
[AbLegacy.md §Transport](AbLegacy.md#transport-transport-option)).
## Native transport behaviour
- **One CIP connection and one request scheduler per device** (`DeviceSessionPool`), shared by reads,
writes, browse and template reads. The Multiple Service Packet scheduler coalesces requests with one
request in flight; large reads (listing pages, Read Template) are sent unpacked and exclusive.
- **Forward Open throttle:** at most one attempt in flight, at least 1 s between starts, bounded by the
open budget.
- `ConnectionSize` / `Timeout` / `AllowPacking` are honoured per pooled connection, and the **first**
driver/device to open a given device key wins — including a Test Connect probe on a fused `admin,driver`
node (its connection carries the probe timeout). A later opener with different options is ignored and
logged once as a Warning (`DeviceSessionPool`). ⚠️ The device key compares the **host string**, so the
same controller addressed by DNS name in one driver and by IP in another gets two connections and two
Forward Open throttles (up to two opens per second); address a controller the same way everywhere. ControlLogix, GuardLogix and (since T21) CompactLogix default to 4002, a Large
Forward Open (0x5B), the same as libplctag. A target that refuses Large gets one regular open at
min(size, 504) inside the same throttled attempt. An explicit `ConnectionSize` in the options or on the
device overrides the family default.
- **Routing:** Micro800 is connected with an empty route regardless of `UseConnectedMessaging`.
SLC500/MicroLogix/PLC-5 over this driver are direct. `logixpccc` uses UnconnectedSend; its SLC framing is
**unverified**.
- ⚠️ **Type check (client-visible change).** The native path compares the type code the controller returns
with the authored `DataType` and reports **`BadTypeMismatch`** on disagreement. libplctag never checks it,
so a same-width mis-authored tag read as **Good garbage**: authoring a tag as `DINT` while the controller
holds a `REAL` returns the REAL's bits as an integer with a Good status under libplctag, and
`BadTypeMismatch` natively. That is the fix working, but a consumer will see Bad quality on tags that
previously looked healthy: correct the authored type. Same-width signed/unsigned pairs are exempt, and
writes are gated on a mismatch too. A mismatch is a **deterministic configuration refusal**, handled like
an Unsupported refusal (final review I4): the tag's handle is kept (no re-create, no reconnect), driver
health is unchanged (no `Degraded`, nothing fed to the comms-loss watchdog), and the Warning is logged
**once per tag** per driver instance, not every poll. The same applies to the libplctag-path width check.
- Malformed tag names put the device into backoff, as under libplctag (#570). STRING capacity includes the
alignment pad (84, not 82) (#570).
- **A refused operation is not a comms fault.** Any operation a runtime refuses as Unsupported (in AbCip, e.g.
a native write whose type cannot be determined) reaches the client as `BadNotSupported`, keeps the tag
handle, and leaves driver health unchanged; only real transport failures evict and degrade.
## Known gaps
- ⚠️ **Native writes are verified against `ab_server` only (simulator-only), never a real controller.** The
real-press gate (Task 21) was **read-only**: it proved native reads, browse and templates on Z-35162
(2026-10-08) and nothing else. Results:
[native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md).
- **libplctag cannot be deleted** until a bench controller allows write verification.
- Wide-struct fragmented writes (8-byte alignment) are unit-tested only, never proven on a controller (#567).
- Native browse: listing pages are unpaced, and template reads outside an enumeration open a connection per
call (#572).
- Micro800, routed and `logixpccc` paths are covered by `ab_server` only. The Micro800 profile passes
natively. The AbLegacy default excludes `logixpccc` (and PLC-5).
- Follow-up issues for this workstream: #564 to #577.
- Fixed along the way (pre-existing on master): `ab://host:44818/` with an empty CIP path (Micro800,
unrouted MicroLogix) failed on libplctag with `ErrorBadData` because the adapter sent `Path=""`; it now
sends no path.
## Alarm Projection
`IAlarmSource` is served by `AbCipAlarmProjection`, which polls each subscribed
@@ -190,7 +312,7 @@ UDT-typed tags found in the walk.
- **Native heap is invisible to the GC.** libplctag's native `Tag` heap does not show up in CLR allocation figures. Watch whole-process RSS, and use `ReinitializeAsync` (tears down + re-creates every device's libplctag handles) as the remediation for native-heap growth. (There is no per-driver footprint number to consult: `IDriver.GetMemoryFootprint()` was deleted as unconsumed in Gitea #525.)
- **Handle eviction on failure** — a non-zero libplctag status or a transport exception evicts the cached tag runtime so the next read/write re-creates a fresh handle, mirroring the probe loop's recreate-on-failure behaviour.
- **Read statuses carry the libplctag cause** — a tag the controller does not have reads `BadNodeIdUnknown` (not `BadCommunicationError`), and it does **not** open the device's connect-backoff window (the controller answered; before this, one typo throttled every sibling tag on the device). A genuine connection failure still opens it.
- **Width check on scalar reads** — libplctag never checks the CIP type the controller returns. A scalar atomic read whose buffer is not the authored type's width (e.g. a REAL authored as INT — the legacy Kepware export does exactly this for some press tags) reads `BadTypeMismatch` instead of Good garbage. Same-width mistakes (DINT vs REAL) are still undetectable.
- **Width check on scalar reads** — libplctag never checks the CIP type the controller returns. A scalar atomic read whose buffer is not the authored type's width (e.g. a REAL authored as INT — the legacy Kepware export does exactly this for some press tags) reads `BadTypeMismatch` instead of Good garbage. Same-width mistakes (DINT vs REAL) are undetectable under libplctag; the `Native` transport reports them as `BadTypeMismatch` (see [Native transport behaviour](#native-transport-behaviour)).
- **Module-defined I/O tags** — `Local:1:I.Data`, `ABB_OmniCore:I.ConnectionFaulted` and similar colon-bearing base names are valid tag paths (controller scope only).
- **User string arrays** — `STR0016[N]` and similar arrays stride by the actual element size (read buffer ÷ count); libplctag.NET 1.5.2 leaves `ElementSize` unset after a read.
- **Declaration-only UDT grouping is a footgun unless verified** — only enable `EnableDeclarationOnlyUdtGrouping` when every UDT's member declaration order has been hand-verified against the controller's compiled layout.
+11
View File
@@ -32,6 +32,17 @@ still the primary coverage. All tests tagged `[Trait("Category", "Unit")]`.
The driver accepts `IAbLegacyTagFactory` via ctor DI; every test
supplies a `FakeAbLegacyTag`.
## Per-transport suites and capture recipe
The `AbLegacy.IntegrationTests` suites now run **once per transport** (`TransportMatrix`: `Libplctag` and
`Native`). Set `AB_LEGACY_COMPOSE_PROFILE` to the running profile. On the 2026-10-07 run native passed
everything libplctag passed on `slc500`, `micrologix` and `plc5` (the PLC-5 test reads N7 only, so the PLC-5
refusal paths and status-bit writes are not exercised by it). Native PCCC is **simulator-only**. Write-capable
tests (the SLC500 N7:5 round trip) require `AB_SERVER_ALLOW_WRITES=1`. Golden frames:
`scripts/ab-capture/capture.sh`, fixtures under
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/`. Evidence:
[native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md).
## What it actually covers (unit only)
- `AbLegacyAddressTests` — PCCC address parsing for SLC / MicroLogix / PLC-5
+87 -4
View File
@@ -93,10 +93,91 @@ binds from the driver's `DriverConfig` JSON:
- **`Devices`** — one `AbLegacyDeviceOptions` per PLC (`HostAddress`, `PlcFamily`, optional `DeviceName`).
- **`Tags`** — pre-declared `AbLegacyTagDefinition` list (`Name`, `DeviceHostAddress`, `Address`, `DataType`, `Writable`, `WriteIdempotent`).
- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeAddress`.
- **`Transport`** — absent (default ⇒ `Libplctag`), `Native` (opt-in), or `Libplctag`; see [Transport](#transport-transport-option).
Full per-field descriptions live in the contracts assembly. The JSON skeleton is
reproduced in [docs/v2/driver-specs.md §4](../v2/driver-specs.md).
## Transport (`transport` option)
Same switch as AB CIP: `transport` = absent \| `Native` \| `Libplctag`, a string (case-insensitive on read,
written as `"Libplctag"` / `"Native"`; "use the default" is encoded by omitting the key). `Native` is a managed
PCCC-over-EtherNet/IP client (`Driver.Ab.Transport`).
**Default resolution** (one place, `AbLegacyTransportResolver`, used by the driver, the Test Connect probe and
the CLI): an absent key resolves to **`Libplctag` for every PLC family** — unchanged from before the native
transport existed. **`Native` is opt-in** (final review I3: on a wire it is proven only for N-file reads and
writes and one F8 read — see [Wire evidence](#wire-evidence-native-pccc)). An explicit value always wins:
explicit `Native` with a PLC-5 / `LogixPccc` device is allowed (an operator choice) and logs one Warning per
driver instance naming the limitation. (AbCip's node-level `Drivers:AbCip:DefaultTransport` has no AbLegacy
counterpart — the AbLegacy default is libplctag and is not node-configurable.)
A change takes effect when the driver instance is constructed: on a deploy that changes `DriverConfig`
(which recreates it) **and on every process start**, including the restart that installs this build (see the
release note). The
AdminUI form's **Transport** dropdown offers "Default (libplctag)" (omits the key on save), "Native (managed,
opt-in)" and "libplctag". The driver logs `transport=<effective> (default|configured)` on every init, e.g.
`transport=Libplctag (default)`. CLI: `--transport default|native|libplctag` (default `default` = libplctag for
every `--plc-type`). See
[AbCip.md §Transport](AbCip.md#transport-transport-option) for the shared behaviour (one pooled connection
per device, Forward Open throttle, routing).
Native routing: SLC500/MicroLogix/PLC-5 are direct; `logixpccc` uses UnconnectedSend and its SLC framing is
**unverified**.
### Release note — native PCCC transport, opt-in (final review I3)
- **The default is unchanged: libplctag.** A driver config with no `transport` key runs `Libplctag`, for
every PLC family, exactly as before this release. The upgrade restart moves nothing. (An interim build on
this branch defaulted SLC 500 / MicroLogix to Native; that was reverted before merge.)
- **`"transport": "Native"` opts a driver into the managed PCCC-over-EtherNet/IP client** (`Driver.Ab.Transport`).
It takes effect when the driver is constructed — a deploy that changes `DriverConfig`, or a process start —
not on `ReinitializeAsync`. The AdminUI **Transport** dropdown authors it; remove the key (or pick
"Default (libplctag)") to go back.
- **Known limits of Native** (why it is opt-in):
- ⚠️ **Wire evidence is narrow and simulator-only** — see [Wire evidence](#wire-evidence-native-pccc). Only
N-file reads and writes and one F8 read have touched a wire (`ab_server`). The **default probe address
`S:0`** and the **B, T, C, R, S, L, ST, A, O and I file types** — and the T/C/R sub-element offsets
(`.PRE`/`.ACC`/`.LEN`/`.POS`) — are encoded from the PCCC file-type table and are **unverified natively**.
No real SLC, MicroLogix or PLC-5 has been exercised. Opt in per driver after checking it against the real
controller.
- ⚠️ **Status-bit writes are refused** — `T4:0.DN`, `T4:0.TT`, `T4:0.EN`, `C5:0.CU`, `C5:0.DN`, `R6:0.EN`
and the rest return **`BadNotSupported`**; nothing is written to the PLC; the handle is kept and health is
unchanged. 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.
- **PLC-5 native is N-file reads only**; every other PLC-5 operation is refused with `BadNotSupported`.
`LogixPccc` framing is unverified. Explicit Native on either logs a Warning at init.
- **ST (string) byte order is unverified** (no capture); string values may be byte-swapped.
- Unlike AbCip, the native AbLegacy runtime has **no element-type check**, so a mis-authored same-width
type reads as reinterpreted data (as on libplctag), not `BadTypeMismatch`.
### Wire evidence (native PCCC)
Everything below is against the `ab_server` simulator; no real PCCC controller has been exercised.
| What | Evidence |
|---|---|
| N-file read (`N7:0`) | libplctag captures `slc500-read-n7`, `micrologix-read-n7`, `plc5-read-n7` (codec byte parity) + native `AbLegacyReadSmokeTests` read on all three profiles |
| N-file write (`N7:5`) | capture `slc500-write-n7` + native `AbLegacyReadSmokeTests` write/read round trip (SLC 500) |
| F-file read (`F8`) | one capture, `slc500-read-f8` (codec parity only; no native F write on a wire) |
| S (incl. the default probe `S:0`), B, T, C, R, L, ST, A, O, I | **none** — encoded from the PCCC file-type table |
| T/C/R sub-elements (`.PRE`/`.ACC`/`.LEN`/`.POS`), 6-byte T/C/R element layout | **none** |
### Native known gaps
- ⚠️ **Simulator-only, and narrow.** Only N-file reads/writes and one F8 read are on a wire (`ab_server`); see
[Wire evidence](#wire-evidence-native-pccc). No real SLC/MicroLogix/PLC-5 has been exercised; the real-press
gate (Task 21) was read-only and AbCip-only.
- **PLC-5 native is N-file reads only.** Every other file type and all PLC-5 writes are refused before the
wire and reach the client as `BadNotSupported` (handle kept, health unchanged; #571 closed). Native is opt-in
for every family; an explicit Native on PLC-5 / `logixpccc` logs a Warning at init.
- Status-bit sub-element writes (e.g. `T4:0.DN`, `C5:0.DN`) are Unsupported natively (they would clobber the
control word); nothing goes on the wire. Sub-element arrays are Unsupported natively. L-file bit indexes
above 15 are allowed.
- **ST (string) file layout is uncaptured**, so the string byte order is unverified natively.
- Capture recipe for golden frames: `scripts/ab-capture/capture.sh` (see
[AbLegacy-Test-Fixture.md](AbLegacy-Test-Fixture.md)). Follow-up issues: #564 to #577.
## Testing
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` cover the driver, the PCCC address parser, and the host-address parser via fake tag runtimes.
@@ -109,14 +190,16 @@ An AB Legacy tag becomes a **1-D OPC UA array node** when its `TagConfig` JSON c
`"isArray": true` and `"arrayLength": N` (N ≥ 1, capped at 256). The canonical rule:
`isArray: true` + `arrayLength >= 1` → array; `isArray: false` (any length) → scalar.
**Read mechanism** — the driver issues a **PCCC multi-element file read** via libplctag.
The tag's file address (e.g. `N7:0`) is extended by `N` element count so libplctag fetches
**Read mechanism** — the driver issues a **PCCC multi-element file read** (libplctag by default; the opt-in
native transport issues the same typed read, chunked under the PCCC payload limit).
The tag's file address (e.g. `N7:0`) is extended by `N` element count so the transport fetches
`N` consecutive PCCC file elements in one EIP/PCCC request. The response is decoded into
N values of the declared `AbLegacyDataType`. Maximum element count is 256 (PCCC payload
limit).
This closes the previously-noted limitation ("single-element addressing today") for the
array read path. Array *writes* remain a follow-up.
array read path. The opt-in native transport also writes multi-element arrays (chunked); on libplctag
array *writes* remain a follow-up.
**Unit test coverage** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` covers
the multi-element read path via the fake tag runtime. The AB Legacy integration fixture is
@@ -124,7 +207,7 @@ down during normal dev.
**Live-verify** — integration-fixture-gated (requires the AB Legacy Docker fixture).
**Deferrals** — array *writes*, multi-dimensional arrays, per-element historization.
**Deferrals** — array *writes* on libplctag (the opt-in native transport writes them), multi-dimensional arrays, per-element historization.
`arrayLength > 256` is rejected at init with an explicit error (PCCC payload cap).
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
+32 -9
View File
@@ -33,6 +33,29 @@ quirk. UDT / alarm / quirk behavior is verified only by unit tests with
- **Endpoint override**: `AB_SERVER_ENDPOINT=host:port` points the
fixture at a real PLC instead of the local container.
## Per-transport suites and capture recipe
Every integration test in `AbCip.IntegrationTests` now runs **once per transport** (`TransportMatrix`:
`Libplctag` and `Native`). Run one compose profile at a time and name it with `AB_SERVER_COMPOSE_PROFILE`
(all profiles bind `:44818`, so set it to the running profile). On the 2026-10-07 run native passed everything
libplctag passed on all seven profiles; on `micro800` libplctag failed on the empty-path defect described in
[AbCip.md](AbCip.md#known-gaps) and native passed. That defect is now fixed: a re-run of the `micro800`
smoke test passes on both transports. Evidence:
[native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md).
- Write-capable tests (the Logix Emulate ALMD test) require `AB_SERVER_ALLOW_WRITES=1` and refuse while
`AB_DIFF_ENDPOINT` is set.
- **Differential harness** (`AbCip.IntegrationTests/Differential`, `LiveIntegration`) compares libplctag and
native on browse, template and read parity against one endpoint. It is read-only by construction, aborts on
the first connection failure, never retries, and caps the native phase at one Forward Open. Env:
`AB_DIFF_ENDPOINT`, `AB_DIFF_PATH`, `AB_DIFF_FAMILY`, `AB_DIFF_TAGS`, `AB_DIFF_MAX_TAGS` (at most 500),
`AB_DIFF_MAX_TEMPLATES`, `AB_DIFF_PACE_MS` (default 20), `AB_DIFF_COUNT_OPENS`. Always run with
`--filter "FullyQualifiedName~Differential"` and `AB_SERVER_ENDPOINT=127.0.0.1:1`.
- ab_server serves no UDTs and refuses 1-byte writes, so template parity is vacuous there and BOOL/SINT
parity is weak evidence. Native writes remain simulator-only.
- **Golden-frame capture:** `scripts/ab-capture/capture.sh` (capture client in `scripts/ab-capture/client`);
fixtures live in `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/` with a README.
## What it actually covers
- Read path: driver → libplctag → CIP-over-EtherNet/IP → simulator → back.
@@ -93,14 +116,14 @@ Only the `_S`-suffix naming classifier (PR 12, `SecurityClassification.ViewOnly`
forced on safety tags) runs. Actual safety-partition write rejection — what
happens when a non-safety write lands on a real `1756-L8xS` — is not exercised.
### 5. CompactLogix narrow ConnectionSize cap
### 5. CompactLogix connection size and the regular-open fallback
CompactLogix profile `Notes`: *"ab_server lacks the narrower limit itself."*
Driver-side `AbCipPlcFamilyProfile` caps `ConnectionSize` at the CompactLogix
value per PR 10, but `ab_server` accepts whatever the client asks for — the
cap's correctness is trusted from its unit test, never stressed against a
simulator that rejects oversized requests.
Since T21 the CompactLogix profile defaults to a **Large** Forward Open (4002), matching what libplctag
opens for `plc=compactlogix`. The old 504 default was cosmetic under libplctag, which never received it,
and cost the native transport ~3× the round trips per poll. A pre-v20 CompactLogix (1769-L3x) or a bridge that refuses
the Large open is handled by `CipConnection`'s fallback (a regular open at min(size, 504) in the same
throttled attempt). That fallback is pinned by unit tests against a fake target only: `ab_server` accepts
any size, so a refusing controller is never exercised here.
### 6. BOOL-within-DINT read-modify-write (#181)
@@ -145,7 +168,7 @@ skip cleanly + the ab_server Docker fixture runs as usual.
| `@tags` Symbol Object walk with `Program:` scope | partial | **yes** | yes |
| Add-On Instructions | no | **yes** | yes |
| GuardLogix safety-partition write rejection | no | **yes** (Emulate 5580) | yes |
| CompactLogix narrow ConnectionSize enforcement | no | **yes** (5370 firmware) | yes |
| CompactLogix Large-open refusal → regular-open fallback | no | **yes** (5370 firmware) | yes |
| EtherNet/IP embedded-switch behaviour | no | no | yes |
| Redundant chassis failover (1756-RM) | no | no | yes |
| Motion control timing | no | no | yes |
@@ -185,7 +208,7 @@ project is authored.
| "Do ALMD alarms raise + clear?" | no | yes | **yes** | yes |
| "Is Micro800 unconnected-only enforced wire-side?" | partial (--plc=Micro800 boots, but wire rejection untested) | partial | yes | yes (required) |
| "Does GuardLogix reject non-safety writes on safety tags?" | no | no | yes (Emulate 5580) | yes |
| "Does CompactLogix refuse oversized ConnectionSize?" | no | partial | yes (5370 firmware) | yes |
| "Does CompactLogix refuse a Large Forward Open?" | no | partial | yes (5370 firmware) | yes |
| "Does BOOL-in-DINT RMW race against concurrent writers?" | no | yes | partial | yes (stress) |
| "Does EtherNet/IP embedded-switch behave correctly?" | no | no | no | yes (required) |
| "Does redundant-chassis failover work?" | no | no | no | yes (required) |
+5
View File
@@ -93,6 +93,11 @@ All tests gate on `TWINCAT_TARGET_NETID` (required) via `[TwinCATFact]` /
`BrowseSymbolsAsync` + system-symbol filtering
- `TwinCATNativeNotificationTests` — `AddDeviceNotification` registration,
callback-delivery-to-`OnDataChange` wiring, unregister on unsubscribe
- `TwinCATNativeArraySubscribeTests` — array tags register with their authored
length, replay after reconnect, and a refused tag is isolated with its status
- `TwinCATArrayCodecTests` — array layout planning / validation
(`TwinCATTypeGuard.CheckArray`, size cap) + raw-bytes decode of every element type
- `TwinCATBitAccessTests` — bit-indexed BOOL notifications served from the parent word
- `TwinCATDriverTests` — `IDriver` lifecycle
Capability surfaces whose contract is verified at the unit layer: `IDriver`,
+118 -20
View File
@@ -202,6 +202,15 @@ a batching delay for high-churn signals. Set `UseNativeNotifications=false` for
deployments where the AMS router has notification limits you can't raise — then
the driver falls through to the shared poll engine.
Strings, arrays and bit-indexed BOOLs are registered as **raw bytes** at the PLC's
resolved width and decoded by the driver, with the same rules the poll read uses
(see [Strings](#strings-and-declared-type-checking) and [Array tags](#array-tags)).
A tag the driver refuses to register — a declared-type mismatch (`BadTypeMismatch`)
or an array over the 128 KiB notification cap (`BadOutOfRange`) — is isolated: it
publishes that status once, logs one WARN, and the rest of the subscription
registers. An ADS error at registration (e.g. a symbol that does not exist) still
fails the whole subscribe call.
## Single-connection-per-device
Each device's ADS client is lazily connected and serialized by a per-device
@@ -219,32 +228,121 @@ writes use the driver-wide `Timeout`.
- **CLI** — [Driver.TwinCAT.Cli.md](../Driver.TwinCAT.Cli.md) documents the
standalone read/write/browse/probe CLI for manual checks.
## 1-D array support
## Array tags
A TwinCAT tag becomes a **1-D OPC UA array node** when its `TagConfig` JSON carries
`"isArray": true` and `"arrayLength": N` (N ≥ 1). The canonical rule:
`isArray: true` + `arrayLength >= 1` → array; `isArray: false` (any length) → scalar.
A TwinCAT tag becomes a **1-D OPC UA array node** (ValueRank 1, `ArrayDimensions = [N]`) when
its `TagConfig` JSON carries `"isArray": true` and `"arrayLength": N` (N ≥ 1). The canonical
rule: `isArray: true` + `arrayLength >= 1` → array; `isArray: false` (any length) → scalar.
`DataType` is the **element** type.
**Read mechanism** — the driver performs an **ADS native array symbol read** against the
declared `SymbolPath`. ADS returns the full array value buffer for a symbol that maps to a
TwinCAT array type (e.g. `ARRAY [0..9] OF REAL`); the driver reads `N` elements from the
response.
### One decode for poll and native notifications
⚠️ **Native notifications do not carry arrays.** The notification path registers the
declared *scalar* type, so before the declared-type check an array tag subscribed natively
published only its first element at Good. It is now refused with `BadTypeMismatch` (the PLC
symbol is an array, not a scalar). Subscribe array tags with `UseNativeNotifications=false`
(the poll engine uses the array read above) until native array notifications are added.
An array is read — by a poll **and** by a native notification — as **raw bytes** at a layout
planned from the PLC's **resolved** array type (`TwinCATTypeGuard.CheckArray`), and decoded by
one codec (`TwinCATArrayCodec`). Both paths therefore publish the identical value: the
element-typed CLR array (`bool[]`, `sbyte[]`, `byte[]`, `short[]`, `ushort[]`, `int[]`,
`uint[]`, `long[]`, `ulong[]`, `float[]`, `double[]`, `string[]`; `TIME` / `DATE` / `DT` /
`TOD` elements as `uint[]` — the same CLR shape their scalars publish), N elements long.
**Unit test coverage** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` covers
the array symbol read path via the fake ADS client factory. The TwinCAT integration
fixture is down during normal dev.
- **A native notification registers the whole array** (element stride × N) as raw bytes, so
the PLC pushes the array on change and every notification decodes to the full array. Before
this the notification path registered only the declared *scalar* — publishing element 0 at
Good — and the declared-type check then refused every array tag. `UseNativeNotifications=false`
is no longer needed for arrays.
- **Elements are checked like a scalar notification**: the declared element type must match the
PLC element's width, float/integer kind and string kind (`DInt` over `ARRAY OF REAL` → refused;
`Int` over `ARRAY OF UINT` — same width, same kind — tolerated). `BYTE` elements map to `SInt`.
- **STRING(n) / WSTRING(n) arrays** are laid out at the PLC's own element width (n+1 /
2(n+1)); each element ends at its own first NUL and decodes with the same Windows-1252 /
UTF-8 rules as a scalar string (see above).
- **Multi-dimensional arrays** (`ARRAY [1..3, 1..2] OF REAL`) are published **flattened** in PLC
memory order (the last index varies fastest); `arrayLength` is the total element count (6).
**Lower bounds play no part**: a PLC `ARRAY [1..20]` is published 0-based, element 0 = PLC
index 1.
- **Sub-range:** an `arrayLength` **shorter** than the PLC array reads the **leading N
elements** (N × stride bytes from the array start) — a smaller read and a smaller
notification, and the value always matches the node's advertised `ArrayDimensions`. A
**longer** `arrayLength` is refused (`BadTypeMismatch`) rather than reading past the array.
- **Refused with `BadTypeMismatch`** (one WARN, that tag only — the rest of the subscription
registers and stays Good): an element-type mismatch, a too-long `arrayLength`, an
**array of structs / UDTs / FBs**, an array of pointers, a nested (jagged) array, an array tag
over a non-array symbol, a `VAR_IN_OUT` array reference, and — on the read path too now — a
**scalar** tag over a PLC array (author it as an array tag, or address one element). Before,
a scalar poll over an array made the Beckhoff read throw and surfaced as
`BadCommunicationError` with the driver Degraded.
- **Element and bit paths stay scalars.** `Arr[3]` is a scalar tag on one element (native and
poll). A bit-indexed BOOL (`MAIN.Flags.3`, `.ADSSTATE_RUN.0`) registers its notification on the
**parent word** as raw bytes and reads the bit — ADS has no symbol for `WORD.N`, and
registering one used to fail with `DeviceSymbolNotFound` and take the whole subscribe call
down (found live, below). A bit outside the parent's width, or a bit of a non-integer parent,
is refused with `BadTypeMismatch`.
**Live-verify** — integration-fixture-gated (requires the TwinCAT Docker fixture / AMS router).
### Notification size cap
**Deferrals** — array *writes* (`ADS WriteValueAsync` for an array), multi-dimensional
arrays, per-element historization. `IRediscoverable` fires and now surfaces a `/hosts` re-browse prompt, but still does not rebuild the address space (#518)
on PLC re-download (unchanged semantics from scalar nodes).
ADS documents no notification size limit, and a live TwinCAT 2.11 target registered and
delivered a **131,072-byte** notification (`P_ETHERCATDIAGNOSIS.SLAVEADDRESS`,
`ARRAY [0..65535] OF UINT`) without complaint. The driver still caps a native array at
**128 KiB** (`TwinCATTypeGuard.MaxNotificationArrayBytes`, the largest size proven live): the
PLC compares the whole buffer every cycle for an on-change notification, and every change is
copied through the router and the driver's notification queue. A bigger array is refused for
native subscription with **`BadOutOfRange`** (one WARN, that tag only) — author a shorter
`arrayLength` (sub-range) or subscribe with `UseNativeNotifications=false`; poll reads have no
cap.
### Why raw bytes, not the Beckhoff typed array read
The poll read used to ask the Beckhoff library for `elementType[]`. Probed live on Z33856, that
failed for most real arrays: the CLR element type had to match the PLC's **exactly** (an
`ARRAY OF TIME` as `uint[]` threw `ArgumentException`), a multi-dimensional array demanded a
rank-N CLR array (`Single[,]`), and a dotted **member** array was sliced by its under-reported
TwinCAT 2 symbol size (`P_INPUTVALUESFILTER.FBUSPWR1G1.SAMPLE`, `ARRAY [1..20] OF REAL`:
symbol 10 B vs type 80 B → `ArgumentOutOfRangeException`). Each surfaced as
`BadCommunicationError`. The raw-bytes model sizes everything from the resolved type, as the
string path already does.
### Live validation (2026-10-07, TwinCAT 2.11 Elma cleaners)
Read-only, against Z33856 (`10.201.45.236.1.1:801`) and Z33733 (`5.25.53.46.1.1:801`) through
an in-process `Beckhoff.TwinCAT.Ads.TcpRouter` scratch harness; one connection at a time, no
writes. Browse found 594 / 583 array symbols (509 / 500 of primitives, 15 / 14 of strings,
17 / 11 multi-dimensional, ~400 with a non-zero lower bound). Each tag was subscribed natively
for 10 s alongside a 1 s poll read; native and poll matched in value, CLR element type and
length for every array, and matched an independent Beckhoff typed read wherever that read
works:
| Symbol | PLC type | Tag | Published |
|---|---|---|---|
| `.ARRFAULTCODE` | `ARRAY [0..12] OF UINT` | UInt × 13 | `UInt16[13]` (element 3 = 32, matching `.ARRFAULTCODE[3]` scalar) |
| `.ARRUSPOWER` | `ARRAY [1..3, 1..2] OF REAL` / `[1..2, 1..2]` | Real × 6 / 4 | `Single[6]` / `Single[4]` — changing (~1060 W on Z33733); **23/23** notifications identical to an immediate poll |
| `P_INPUTVALUESFILTER.FBUSPWR1G1.SAMPLE` | `ARRAY [1..20] OF REAL` (member, symbol 10 B) | Real × 20, and × 5 (sub-range) | `Single[20]` changing, **14/14** identical; `Single[5]` = its first 5 |
| `.THROTTLE_MODE_DEFAULT` | `ARRAY [0..12] OF TIME` | Time × 13 | `UInt32[13]` `[0, 10, 20, 40, …]` ms |
| `.ARGTYPE_IS_BIT_TYPE` | `ARRAY [0..18] OF BOOL` | Bool × 19 | `Boolean[19]` |
| `.FORMAT_DEFAULT_PRECISION` / `.DWORD_DECADE_DIVIDER` / `.DIXTIMEALARM` | `INT` / `DWORD` / `UDINT` arrays | Int / UDInt | `Int16[12]` / `UInt32[10]` / `UInt32[101]` |
| `.IMQN_TABLE` / `.AMSNETID` / `.ARRMODULENO` | `LREAL` / `USINT` / `BYTE` arrays | LReal / USInt / SInt | `Double[32]` / `Byte[6]` / `SByte[13]` (changing on Z33856) |
| `FAST.COMPORTCONTROL.COMPORTDEBUGBUFFER.BUFFER` | `ARRAY [0..300] OF INT` (member) | Int × 301 | `Int16[301]`, changing every cycle; 18–19 of ~59 notifications identical to an immediate poll (the rest changed in between) |
| `P_ALARMS.ARRSTATE` / `.ARRHEADER` | `ARRAY [1..n] OF STRING(80)` | String | `["RUN", "RUN", "RUN", "RUN", "MON_ON", "", …]` / `["Time1", "UsTime1 bottom", …]` |
| `.ARRDATA` | `ARRAY [0..5, 0..17] OF STRING(80)` (8,748 B) | String × 108 | `String[108]` |
| `P_ETHERCATDIAGNOSIS.SLAVEADDRESS` | `ARRAY [0..65535] OF UINT` (131,072 B) | UInt × 65536 | `UInt16[65536]` `[1001, 1002, …]` — at the cap |
Refused per tag with `BadTypeMismatch`, on both paths, while every sibling stayed Good: `DInt`
over `.ARRUSPOWER` (REAL elements), `arrayLength` 14 over the 13-element `.ARRFAULTCODE`,
`DInt` × 13 over `.ARRCARRIERDATA_V` (`ARRAY OF ST_CARRIERDATA`), scalar `UInt` over
`.ARRFAULTCODE`, and an array of 2 over the scalar `.ADSSTATE_RUN`. The bit tag
`.ADSSTATE_RUN.0` = TRUE natively (it previously failed to register at all).
### Coverage and deferrals
**Unit tests** — `TwinCATArrayCodecTests` (layout planning, element checks, sub-range, size
cap, decode of every element type), `TwinCATNativeArraySubscribeTests` (the driver passes the
authored length, replays it after a reconnect, isolates a refused tag with the refusal's own
status), `TwinCATBitAccessTests`. The Beckhoff `AdsClient` cannot be faked, so the adapter
stays a thin shell over these pure classes.
**Deferrals** — array *writes* (`ADS WriteValueAsync` for an array), per-element
historization, and **controller browse of multi-dimensional arrays** (discovery still drops
them; author such a tag by hand with `arrayLength` = the total element count).
`IRediscoverable` fires and now surfaces a `/hosts` re-browse prompt, but still does not
rebuild the address space (#518) on PLC re-download (unchanged semantics from scalar nodes).
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
and the UI authoring flow.
@@ -185,6 +185,66 @@ see the same split on the press.
The ab_server controllogix matrix is read-only and unaffected: 15 passed and 11 skipped with
`AB_SERVER_COMPOSE_PROFILE=controllogix`.
### Latency harness proven on ab_server (T21 Step 1)
`Differential/PollLatencyComparisonTests.cs` (+ `PollLatencyRun.cs`) ran 2026-10-07 against ab_server
(`controllogix` image, host port 44828 because an unrelated outbound socket held 44818 on the docker host),
under the same `--filter "FullyQualifiedName~Differential"` invocation as the differential. The mechanics work:
the timed phase reuses the differential's browse, the anchors, the open budget and the barriers; **1** native Forward Open
in the latency phase; 30 passes per transport, 0 tag failures; native packs 100 concurrent reads into **6** requests per pass.
ab_server has 7 atomic scalars, so the 100-tag set was **repeated cyclically** (reported in the output).
| Family | libplctag p50 / p95 ms | native p50 / p95 ms | native requests/pass | Criterion |
|---|---|---|---|---|
| CompactLogix (504, the press default) | 7.7 / 10.9 | 22.1 / 25.3 | 6 | **FAIL** |
| ControlLogix (4002) | 7.3 / 9.1 | 7.7 / 9.2 | 2 | pass |
The CompactLogix result was a real finding, not a harness fault. Native opened a 504-byte connection there, while libplctag
opened a Large one (see the T18 capture above), so native needed 3× the round trips per pass.
**Fixed:** the CompactLogix profile now defaults to 4002, so native opens Large like libplctag. A refusal still falls back to
a regular 504 open. Re-run 2026-10-07 23:30–23:32Z against the `compactlogix` compose profile on :44818 (3 distinct atomic
tags, repeated to 100), with a capture in the container's network namespace:
| Run | libplctag p50 / p95 ms | native p50 / p95 ms | native req/pass | Criterion |
|---|---|---|---|---|
| 1 | — | — | — | aborted in the latency libplctag phase (see below) |
| 2 | 7.6 / 9.6 | 7.5 / 10.8 | 2 | FAIL (limit 10.6) |
| 3 | 7.8 / 9.3 | 8.1 / 9.4 | 2 | PASS (limit 10.2) |
| 4 | 7.7 / 10.0 | 7.8 / 11.7 | 2 | FAIL (limit 11.1) |
| 5 | — | — | — | aborted in the latency libplctag phase |
The structural gap is gone: p50s are equal, and both transports now send 2 requests per pass. At ~10 ms the p95 margin is about
1 ms, which is within this network path's jitter, so the ab_server verdict varies from run to run.
Capture: every session opened with **0x5B (Large Forward Open)** and was accepted (0xDB, status 0). That covers 15 sessions:
four per complete invocation and three in the aborted run 5. Each session had one Forward Close (0x4E to Connection Manager 6/1).
There were **zero** 0x4D/0x53/0x4B. Both aborts (runs 1 and 5) were the simulator: libplctag's ~2 KB packed request was split
across TCP segments (1448 + 606), and ab_server answered with FIN. The harness aborted on the first connection-level failure,
as designed. A real controller reassembles TCP; ab_server does not.
The T21 press result is recorded under `### Pass 3 — 2026-10-08 02:27–02:30 UTC (HEAD `8e2dbc67`, after the final-review fixes; same settings as pass 2) — with packet capture
**17/17 passed.** Browse 3162 = 3162 tags; 20 UDT templates identical; 200 tags read with 0 unexpected divergences
(6 live); native 1 Forward Open, Forward Closes sent/acked 1/1; latency native p95 105.1 ms ≤ libplctag p95 197.3 ms
×1.10 = 217.1 ms → PASS. Raw output: [pass 3](2026-10-07-native-ab-transport-t21/t21-pass3.txt).
Packet capture (`tcpdump -i utun4 host 10.209.70.160 and port 44818`, analysed with TCP sequence reassembly):
| Session | Order | Window (s) | CIP services seen (requests) | Forward Close target |
|---|---|---|---|---|
| libplctag differential | 1 | 0.0 – 82.5 | 0x03, 0x4C, 0x52, 0x55, 0x5B ×1, 0x4E ×1 | Connection Manager 6/1 |
| native differential | 2 | 82.5 – 126.3 | 0x03, 0x4C, 0x52, 0x55, 0x5B ×1, 0x4E ×1 (+ UnRegisterSession) | Connection Manager 6/1 |
| libplctag latency | 3 | 126.5 – 152.4 | 0x0A (MSP), 0x52, 0x5B ×1, 0x4E ×1 | Connection Manager 6/1 |
| native latency | 4 | 152.4 – 176.5 | 0x0A (MSP), 0x52, 0x5B ×1, 0x4E ×1 (+ UnRegisterSession) | Connection Manager 6/1 |
- **Zero write-class services** (no 0x4D Write Tag, 0x53 Write Tag Fragmented, 0x4B Execute PCCC, and no 0x4E to
anything but the Connection Manager).
- **Four sessions, strictly sequential** — each starts after the previous one's last packet; never two at once.
- Every session opened exactly one Large Forward Open and closed it with one Forward Close.
## Latency (T21)` and is not filled in here.
## AdminUI /run (T19)
Run 2026-10-07 on the local docker-dev rig (OrbStack), against the ab_server `controllogix` fixture
@@ -260,8 +320,160 @@ falsified by reverting that wiring: 2/2 tests fail.
## Real press (T21)
_(pending)_
Target: **Z-35162**, production CompactLogix 5069-L320ERS2, `10.209.70.160:44818`, path `1,0`, family CompactLogix
(native connection size 4002, Large Forward Open). Read-only. User approved the press and each time window; VPN up;
no other client on this Mac connected to the press. Driven from the Mac over VPN (≈44 ms RTT).
### Pass 1 — 2026-10-07 23:35–23:36 UTC (HEAD `c306c6f6`; 25 tags, 20 templates, pace 50 ms, latency 100×30)
12/14 passed. **Safety held**: packet capture (`tcpdump` on `utun4`) shows 4 sequential sessions, each one Large
Forward Open (0x5B) and one Forward Close (0x4E, Connection Manager 6/1, acked); services seen only 0x03, 0x0A, 0x4C,
0x52, 0x55, 0x5B, 0x4E; **zero writes** (no 0x4D/0x53/0x4B). Browse parity and all 20 UDT template shapes identical;
native Forward Opens = 1.
Failures, and what they led to:
- **Read parity, 2 tags** — live analog values moved between the sequential libplctag and native phases
(`WW_M4ActualTemp` 384.887 → 385.138). Harness defect: it could not tell drift from divergence. Fixed by a volatility
probe (`82a0c298`, tightened element-wise in `43b81b66`): a value that moves between two libplctag reads is
compared by status, length, type, shape and **every element that held still**.
- **Latency** — native p95 142.8 ms vs libplctag 99.1 ms ×1.10 = 109.1 ms (FAIL), medians native 93.7 / libplctag
95.2 ms; three native outliers in 30 passes. Criterion unchanged; pass count raised to 50 and per-pass GC/RTT
diagnostics added.
- **Test host crashed at exit** — `A callback was made on a garbage collected delegate … callback_func_ex`. Root cause
is in libplctag.NET 1.5.2: a failed tag create leaves its native callback registered and `Dispose()` doesn't release
it. **Production-reachable** on the default transport (a nonexistent tag name crashes the server node at
stop/restart). Fixed in the adapters (`6cd68762`, hardened `6ae62873`); Gitea #578.
- Found before pass 1, on ab_server: native CompactLogix opened a 504-byte connection (the family default libplctag
never honoured) and needed ~3× the round-trips. Native default changed to 4002 with the existing Large→regular
fallback (`c306c6f6`).
### Pass 2 — 2026-10-08 00:21–00:24 UTC (HEAD `df776170`, fresh build; 200 tags, 20 templates, pace 50 ms, latency 100×50)
**17/17 passed.**
| Check | Result |
|---|---|
| Browse parity | libplctag 3162 tags, native 3162 — identical sets |
| UDT templates | 20 compared (of 74 found, capped) — identical shapes |
| Read parity | 200 tags: **0 unexpected**, 0 expected (TypeMismatch) divergences; 6 live tags (below) |
| Native Forward Opens | 1 per native phase |
| Native Forward Closes sent/acked | 1/1 per native phase; 0 connections left |
| Sessions | libplctag and native never connected at the same time |
| Writes | none — read-only source guard passes; `grep -c WriteAsync` = 0 over every Differential file |
| libplctag failed-create orphans | 15 destroyed by the #578 fix, 0 rooted — no crash at exit |
⚠️ **Pass 2 has no independent packet capture** (pass 3 below re-ran it with one): the operator's `tcpdump` had stopped at 23:45 UTC, before pass 2 began,
and this was not noticed until afterwards. The read-only evidence for pass 2 is the source guard and the harness's
own Forward Open/Close counters; pass 1's capture is the wire-level proof for the same code paths.
Live tags (value moved between the two libplctag reads; every element that held still matched native):
```
- live: WW_M4ActualTemp (lib 4 byte(s) 0CCDC243 = 389.60193; lib probe 4 byte(s) 7084C243 = 389.03467; native 4 byte(s) 4C47C243 = 388.557)
- live: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5 (lib 20 byte(s) 0050C348218986402189A640DF76D9C021898640 = [400000,4.2042394,5.2042394,-6.7957606,4.2042394]; lib probe 20 byte(s) 0050C348E74B8640E74BA64019B4D9C0E74B8640 = [400000,4.1967654,5.1967654,-6.8032346,4.1967654]; native 20 byte(s) 0050C348BB328640BB32A64045CDD9C0BB328640 = [400000,4.1936927,5.1936927,-6.8063073,4.1936927])
- live: Program:EM02_Clamp.__fout2F67CD35C2302EE0 (lib 16 byte(s) BFB2BF379A9919BF00D68349CDCCCCBE = [2.285221E-05,-0.6,1080000,-0.4]; lib probe 16 byte(s) 5119313A9A9919BF00D68349CDCCCCBE = [0.00067557866,-0.6,1080000,-0.4]; native 16 byte(s) 1562C2B99A9919BF00D68349CDCCCCBE = [-0.0003707564,-0.6,1080000,-0.4])
- live: Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB (lib 16 byte(s) 00E14CC0CDDA75BF000000C08F2D2BC0 = [-3.201233,-0.9603699,-2,-2.6746557]; lib probe 16 byte(s) 009464C09A2589BF000000C0A84932C0 = [-3.5715332,-1.07146,-2,-2.7857456]; native 16 byte(s) 00A03AC034F35FBF000000C0A8B325C0 = [-2.9160156,-0.87480474,-2,-2.5890903])
- live: Program:ES03_EM01_Zone1.__fout92848E133B44AF24 (lib 32 byte(s) 40EDA94160F0D043000090C10022A14980256C41C656D143006A4F400000A8C1 = [21.240845,417.87793,-18,1320000,14.759155,418.67792,3.2408447,-21]; lib probe 32 byte(s) 6017AD412089D043000090C10022A14940D1654186EFD04300BB68400000A8C1 = [21.636414,417.0713,-18,1320000,14.363586,417.87128,3.6364136,-21]; native 32 byte(s) C047A841BE97CF43000090C10022A14980706F4124FECF43003E42400000A8C1 = [21.035034,415.1855,-18,1320000,14.964966,415.98547,3.0350342,-21])
- live: Program:ES03_EM01_Zone1.__fout6DC7745628658A9D (lib 8 byte(s) 00824E40CD34A53F = [3.2266846,1.2906739]; lib probe 8 byte(s) 008A6440CDD4B63F = [3.5709229,1.4283692]; native 8 byte(s) 000D3940670A943F = [2.8914185,1.1565675])
```
Test summary:
```
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [26 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [17 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [4 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [15 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [3 m 2 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_closes_its_one_connection_with_exactly_one_forward_close [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.No_failed_libplctag_tag_is_left_with_a_live_native_callback [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.A_live_value_is_compared_structurally_and_a_stable_one_by_value [3 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [36 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [3 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
Total tests: 17
Passed: 17
```
Full raw `dotnet test` output: [pass 1](2026-10-07-native-ab-transport-t21/t21-pass1.txt), [pass 2](2026-10-07-native-ab-transport-t21/t21-pass2.txt).
## Latency (T21)
_(pending)_
Pass 2 (100 atomic tags × 50 passes per transport, 200 ms gap, all reads in a pass issued concurrently):
| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
|---|---|---|---|---|---|---|---|
| libplctag | 50 | 107.0 | 139.5 | 57.9 | 164.8 | n/a | 0 |
| native | 50 | 100.3 | 105.8 | 94.8 | 107.5 | 2 | 0 |
**Criterion: native p95 105.8 ms ≤ libplctag p95 139.5 ms × 1.10 = 153.4 ms → PASS.** Native was faster at the median
and much steadier (max 107.5 ms vs 164.8 ms), packing 95 reads into 2 CIP requests per pass. Five pad/config tags that
libplctag refused at setup were removed from both sets (`Local:*:I.Pad1`, `Local:5:C.CfgSize`, `Local:5:C.CfgIDNum`).
Pass 1 (30 passes) failed this criterion on three native outliers (p95 142.8 vs limit 109.1; medians native 93.7 /
libplctag 95.2) — see above.
Per-pass detail (pass 2):
```
| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |
|---|---|---|---|---|---|---|
| 1 | 104.8 | 0/0/0 | 100.3 | 0/0/0 | 2 | 44.9/49.6/54.4 |
| 2 | 57.9 | 0/0/0 | 100.5 | 0/0/0 | 2 | 47.0/50.1/53.3 |
| 3 | 139.5 | 0/0/0 | 105.8 | 0/0/0 | 2 | 45.3/52.9/60.4 |
| 4 | 104.5 | 0/0/0 | 100.6 | 1/0/0 | 2 | 47.3/50.2/53.2 |
| 5 | 108.0 | 0/0/0 | 105.3 | 0/0/0 | 2 | 44.5/52.6/60.6 |
| 6 | 104.4 | 0/0/0 | 105.6 | 0/0/0 | 2 | 44.2/52.7/61.2 |
| 7 | 60.8 | 0/0/0 | 98.7 | 0/0/0 | 2 | 44.3/49.3/54.2 |
| 8 | 59.6 | 0/0/0 | 102.6 | 0/0/0 | 2 | 47.4/51.2/55.0 |
| 9 | 109.2 | 0/0/0 | 97.0 | 0/0/0 | 2 | 45.9/48.4/50.9 |
| 10 | 109.7 | 0/0/0 | 99.3 | 0/0/0 | 2 | 45.9/49.6/53.3 |
| 11 | 105.9 | 0/0/0 | 103.0 | 0/0/0 | 2 | 46.2/51.4/56.7 |
| 12 | 107.7 | 0/0/0 | 95.1 | 0/0/0 | 2 | 43.3/47.5/51.7 |
| 13 | 104.7 | 0/0/0 | 104.0 | 0/0/0 | 2 | 45.7/52.0/58.2 |
| 14 | 106.8 | 0/0/0 | 96.5 | 0/0/0 | 2 | 43.6/48.2/52.7 |
| 15 | 107.2 | 0/0/0 | 102.4 | 0/0/0 | 2 | 46.1/51.1/56.2 |
| 16 | 105.5 | 0/0/0 | 97.4 | 0/0/0 | 2 | 44.7/48.6/52.6 |
| 17 | 105.9 | 0/0/0 | 107.5 | 0/0/0 | 2 | 51.0/53.7/56.4 |
| 18 | 103.2 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.0/48.2/53.5 |
| 19 | 106.5 | 0/0/0 | 102.1 | 0/0/0 | 2 | 45.5/51.0/56.4 |
| 20 | 106.8 | 0/0/0 | 99.6 | 0/0/0 | 2 | 44.6/49.7/54.9 |
| 21 | 122.8 | 0/0/0 | 104.5 | 0/0/0 | 2 | 52.1/52.2/52.3 |
| 22 | 109.8 | 0/0/0 | 104.3 | 0/0/0 | 2 | 45.8/52.1/58.5 |
| 23 | 104.3 | 0/0/0 | 98.6 | 0/0/0 | 2 | 45.4/49.2/53.0 |
| 24 | 109.7 | 0/0/0 | 98.8 | 0/0/0 | 2 | 45.9/49.3/52.8 |
| 25 | 155.1 | 0/0/0 | 101.0 | 0/0/0 | 2 | 45.5/50.4/55.4 |
| 26 | 164.8 | 0/0/0 | 102.9 | 0/0/0 | 2 | 43.9/51.4/58.8 |
| 27 | 120.4 | 0/0/0 | 101.4 | 0/0/0 | 2 | 48.2/50.7/53.1 |
| 28 | 108.6 | 0/0/0 | 101.7 | 0/0/0 | 2 | 46.9/50.8/54.7 |
| 29 | 106.0 | 0/0/0 | 98.5 | 0/0/0 | 2 | 45.5/49.2/52.9 |
| 30 | 102.9 | 0/0/0 | 94.9 | 0/0/0 | 2 | 43.6/47.4/51.2 |
| 31 | 111.3 | 0/0/0 | 99.9 | 0/0/0 | 2 | 45.6/49.9/54.2 |
| 32 | 103.3 | 0/0/0 | 96.4 | 0/0/0 | 2 | 43.6/48.1/52.6 |
| 33 | 105.8 | 0/0/0 | 104.6 | 0/0/0 | 2 | 46.4/52.2/58.0 |
| 34 | 109.3 | 0/0/0 | 95.8 | 0/0/0 | 2 | 43.2/47.8/52.4 |
| 35 | 110.2 | 0/0/0 | 102.1 | 0/0/0 | 2 | 46.3/51.0/55.7 |
| 36 | 108.0 | 0/0/0 | 101.7 | 0/0/0 | 2 | 47.5/50.8/54.0 |
| 37 | 107.6 | 0/0/0 | 96.7 | 0/0/0 | 2 | 45.0/48.3/51.5 |
| 38 | 108.9 | 0/0/0 | 96.5 | 0/0/0 | 2 | 44.7/48.2/51.6 |
| 39 | 105.2 | 0/0/0 | 98.2 | 0/0/0 | 2 | 45.6/49.0/52.5 |
| 40 | 106.7 | 0/0/0 | 99.4 | 0/0/0 | 2 | 44.4/49.6/54.9 |
| 41 | 107.0 | 0/0/0 | 101.8 | 0/0/0 | 2 | 46.9/50.8/54.8 |
| 42 | 108.6 | 0/0/0 | 106.7 | 0/0/0 | 2 | 49.2/53.3/57.4 |
| 43 | 107.0 | 0/0/0 | 102.6 | 0/0/0 | 2 | 49.9/51.2/52.6 |
| 44 | 107.7 | 0/0/0 | 100.3 | 0/0/0 | 2 | 47.4/50.1/52.7 |
| 45 | 109.5 | 0/0/0 | 102.6 | 0/0/0 | 2 | 45.8/51.2/56.7 |
| 46 | 107.6 | 0/0/0 | 104.0 | 0/0/0 | 2 | 48.4/52.0/55.5 |
| 47 | 115.6 | 0/0/0 | 99.0 | 0/0/0 | 2 | 45.9/49.4/52.9 |
| 48 | 106.0 | 0/0/0 | 97.8 | 0/0/0 | 2 | 44.9/48.8/52.8 |
| 49 | 104.5 | 0/0/0 | 98.4 | 0/0/0 | 2 | 46.1/49.1/52.2 |
| 50 | 112.5 | 0/0/0 | 94.8 | 1/1/1 | 2 | 43.0/47.3/51.7 |
```
@@ -0,0 +1,307 @@
Test run for /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.1 (arm64)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.0.2+dd36e86129 (64-bit .NET 10.0.5)
[xUnit.net 00:00:00.33] Discovering: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
[xUnit.net 00:00:00.46] Discovered: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
[xUnit.net 00:00:00.57] Starting: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [11 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [7 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [2 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
Standard Output Messages:
libplctag connects before the abort: 1
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [13 ms]
[xUnit.net 00:01:15.30] ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [FAIL]
[xUnit.net 00:01:15.30] Shouldly.ShouldAssertException : natP95
[xUnit.net 00:01:15.30] should be less than or equal to
[xUnit.net 00:01:15.30] 109.06115000000001d
[xUnit.net 00:01:15.30] but was
[xUnit.net 00:01:15.30] 142.765d
[xUnit.net 00:01:15.30]
[xUnit.net 00:01:15.30] Additional Info:
[xUnit.net 00:01:15.30] native p95 must be ≤ libplctag p95 × 1.10
[xUnit.net 00:01:15.30] Stack Trace:
[xUnit.net 00:01:15.30] /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs(74,0): at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag()
[xUnit.net 00:01:15.30] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
[xUnit.net 00:01:15.30] --- End of stack trace from previous location ---
[xUnit.net 00:01:15.30] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
[xUnit.net 00:01:15.30] Output:
[xUnit.net 00:01:15.30] ## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
[xUnit.net 00:01:15.30] Tags per pass: 95 (100 distinct); passes per transport: 30; gap between passes: 200 ms.
[xUnit.net 00:01:15.30] Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
[xUnit.net 00:01:15.30] Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
[xUnit.net 00:01:15.30] Native requests spent on setup (one read per tag): 95; native Forward Opens: 1.
[xUnit.net 00:01:15.30]
[xUnit.net 00:01:15.30] | Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
[xUnit.net 00:01:15.30] |---|---|---|---|---|---|---|---|
[xUnit.net 00:01:15.30] | libplctag | 30 | 95.2 | 99.1 | 90.8 | 179.2 | n/a | 0 |
[xUnit.net 00:01:15.30] | native | 30 | 93.7 | 142.8 | 88.8 | 146.1 | 2 | 0 |
[xUnit.net 00:01:15.30]
[xUnit.net 00:01:15.30] Criterion: native p95 142.8 ms ≤ libplctag p95 99.1 ms × 1.10 = 109.1 ms → **FAIL**
[xUnit.net 00:01:15.30]
[xUnit.net 00:01:15.30] | Pass | libplctag ms | native ms | native requests |
[xUnit.net 00:01:15.30] |---|---|---|---|
[xUnit.net 00:01:15.30] | 1 | 95.0 | 95.4 | 2 |
[xUnit.net 00:01:15.30] | 2 | 94.4 | 92.4 | 2 |
[xUnit.net 00:01:15.30] | 3 | 95.8 | 96.8 | 2 |
[xUnit.net 00:01:15.30] | 4 | 95.0 | 94.7 | 2 |
[xUnit.net 00:01:15.30] | 5 | 98.8 | 93.7 | 2 |
[xUnit.net 00:01:15.30] | 6 | 92.4 | 101.0 | 2 |
[xUnit.net 00:01:15.30] | 7 | 94.1 | 94.1 | 2 |
[xUnit.net 00:01:15.30] | 8 | 97.0 | 96.7 | 2 |
[xUnit.net 00:01:15.30] | 9 | 93.4 | 91.2 | 2 |
[xUnit.net 00:01:15.30] | 10 | 97.7 | 91.0 | 2 |
[xUnit.net 00:01:15.30] | 11 | 94.6 | 91.1 | 2 |
[xUnit.net 00:01:15.30] | 12 | 96.5 | 92.0 | 2 |
[xUnit.net 00:01:15.30] | 13 | 94.1 | 97.6 | 2 |
[xUnit.net 00:01:15.30] | 14 | 96.1 | 94.0 | 2 |
[xUnit.net 00:01:15.30] | 15 | 90.8 | 93.3 | 2 |
[xUnit.net 00:01:15.30] | 16 | 95.6 | 93.7 | 2 |
[xUnit.net 00:01:15.30] | 17 | 93.9 | 100.6 | 2 |
[xUnit.net 00:01:15.30] | 18 | 92.4 | 121.6 | 2 |
[xUnit.net 00:01:15.30] | 19 | 97.5 | 92.7 | 2 |
[xUnit.net 00:01:15.30] | 20 | 95.2 | 93.8 | 2 |
[xUnit.net 00:01:15.30] | 21 | 96.8 | 90.4 | 2 |
[xUnit.net 00:01:15.30] | 22 | 98.6 | 99.1 | 2 |
[xUnit.net 00:01:15.30] | 23 | 92.1 | 146.1 | 2 |
[xUnit.net 00:01:15.30] | 24 | 99.1 | 142.8 | 2 |
[xUnit.net 00:01:15.30] | 25 | 96.6 | 91.7 | 2 |
[xUnit.net 00:01:15.30] | 26 | 96.9 | 91.4 | 2 |
[xUnit.net 00:01:15.30] | 27 | 179.2 | 92.0 | 2 |
[xUnit.net 00:01:15.30] | 28 | 98.5 | 91.7 | 2 |
[xUnit.net 00:01:15.30] | 29 | 95.0 | 88.8 | 2 |
[xUnit.net 00:01:15.30] | 30 | 95.0 | 93.4 | 2 |
Failed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [1 m 6 s]
Error Message:
Shouldly.ShouldAssertException : natP95
should be less than or equal to
109.06115000000001d
but was
142.765d
Additional Info:
native p95 must be ≤ libplctag p95 × 1.10
Stack Trace:
at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag() in /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs:line 74
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
--- End of stack trace from previous location ---
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
Standard Output Messages:
## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
Tags per pass: 95 (100 distinct); passes per transport: 30; gap between passes: 200 ms.
Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
Native requests spent on setup (one read per tag): 95; native Forward Opens: 1.
| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
|---|---|---|---|---|---|---|---|
| libplctag | 30 | 95.2 | 99.1 | 90.8 | 179.2 | n/a | 0 |
| native | 30 | 93.7 | 142.8 | 88.8 | 146.1 | 2 | 0 |
Criterion: native p95 142.8 ms ≤ libplctag p95 99.1 ms × 1.10 = 109.1 ms → **FAIL**
| Pass | libplctag ms | native ms | native requests |
|---|---|---|---|
| 1 | 95.0 | 95.4 | 2 |
| 2 | 94.4 | 92.4 | 2 |
| 3 | 95.8 | 96.8 | 2 |
| 4 | 95.0 | 94.7 | 2 |
| 5 | 98.8 | 93.7 | 2 |
| 6 | 92.4 | 101.0 | 2 |
| 7 | 94.1 | 94.1 | 2 |
| 8 | 97.0 | 96.7 | 2 |
| 9 | 93.4 | 91.2 | 2 |
| 10 | 97.7 | 91.0 | 2 |
| 11 | 94.6 | 91.1 | 2 |
| 12 | 96.5 | 92.0 | 2 |
| 13 | 94.1 | 97.6 | 2 |
| 14 | 96.1 | 94.0 | 2 |
| 15 | 90.8 | 93.3 | 2 |
| 16 | 95.6 | 93.7 | 2 |
| 17 | 93.9 | 100.6 | 2 |
| 18 | 92.4 | 121.6 | 2 |
| 19 | 97.5 | 92.7 | 2 |
| 20 | 95.2 | 93.8 | 2 |
| 21 | 96.8 | 90.4 | 2 |
| 22 | 98.6 | 99.1 | 2 |
| 23 | 92.1 | 146.1 | 2 |
| 24 | 99.1 | 142.8 | 2 |
| 25 | 96.6 | 91.7 | 2 |
| 26 | 96.9 | 91.4 | 2 |
| 27 | 179.2 | 92.0 | 2 |
| 28 | 98.5 | 91.7 | 2 |
| 29 | 95.0 | 88.8 | 2 |
| 30 | 95.0 | 93.4 | 2 |
[xUnit.net 00:01:15.35] ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [FAIL]
[xUnit.net 00:01:15.35] Shouldly.ShouldAssertException : unexpected
[xUnit.net 00:01:15.35] should be empty but had
[xUnit.net 00:01:15.35] 2
[xUnit.net 00:01:15.35] items and was
[xUnit.net 00:01:15.35] ["WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)", "Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])"]
[xUnit.net 00:01:15.35] Stack Trace:
[xUnit.net 00:01:15.35] /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs(123,0): at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports()
[xUnit.net 00:01:15.35] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
[xUnit.net 00:01:15.35] --- End of stack trace from previous location ---
[xUnit.net 00:01:15.35] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
[xUnit.net 00:01:15.35] Output:
[xUnit.net 00:01:15.35] ## Reads — 25 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
[xUnit.net 00:01:15.35] Selection by origin: array 9, udt member 8, scalar 8
[xUnit.net 00:01:15.35] | Tag | Type | libplctag status | native status | Equal? |
[xUnit.net 00:01:15.35] |---|---|---|---|---|
[xUnit.net 00:01:15.35] | `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:1:I.InfoBits` | SInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `__CONTAINER` | DInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:1:I.RunMode` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `WW_M4ActualTemp` | Real | Ok | Ok | **NO: raw** |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `WW_P1_Status_Msg` | DInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **NO: raw** |
[xUnit.net 00:01:15.35] | `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `DI_CycleStart_PB` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `test` | DInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:1:I.Pad1` | Int | NotFound | NotFound | yes |
[xUnit.net 00:01:15.35] | `UN01_EventsPTR` | DInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:2:I.InfoBits` | SInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Sts_Vacuum_Rerun` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:2:I.RunMode` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Ons_VacuumRerun` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | yes |
[xUnit.net 00:01:15.35]
[xUnit.net 00:01:15.35] Unexpected divergences: 2; expected divergences: 0.
[xUnit.net 00:01:15.35] - UNEXPECTED: WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)
[xUnit.net 00:01:15.35] - UNEXPECTED: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])
[xUnit.net 00:01:15.35] Finished: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
Process terminated.
A callback was made on a garbage collected delegate of type 'libplctag.NativeImport!libplctag.NativeImport.plctag+callback_func_ex::Invoke'.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [31 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
Standard Output Messages:
## Browse — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
libplctag: 3162 tag(s); native: 3162 tag(s)
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
Standard Output Messages:
## Templates — 20 id(s); truncated by AB_DIFF_MAX_TEMPLATES: libplctag 74, native 74
| Template | libplctag | native | Equal? |
|---|---|---|---|
| 283 | Recipe (5 members, 1680 B) | Recipe (5 members, 1680 B) | yes |
| 295 | UDT_PartStatus (5 members, 4 B) | UDT_PartStatus (5 members, 4 B) | yes |
| 344 | Recipe_Clamp (7 members, 420 B) | Recipe_Clamp (7 members, 420 B) | yes |
| 491 | ForceSegmentEnableCheck (3 members, 4 B) | ForceSegmentEnableCheck (3 members, 4 B) | yes |
| 527 | Recipe_PowerUnit (6 members, 24 B) | Recipe_PowerUnit (6 members, 24 B) | yes |
| 622 | UDT_TemperatureProfile_Segment (11 members, 40 B) | UDT_TemperatureProfile_Segment (11 members, 40 B) | yes |
| 676 | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | yes |
| 700 | AB:5000_AI4CJ:I:0 (12 members, 68 B) | AB:5000_AI4CJ:I:0 (12 members, 68 B) | yes |
| 706 | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | yes |
| 715 | TwoHand_Control (8 members, 16 B) | TwoHand_Control (8 members, 16 B) | yes |
| 752 | STR0016 (2 members, 20 B) | STR0016 (2 members, 20 B) | yes |
| 842 | STR0032 (2 members, 36 B) | STR0032 (2 members, 36 B) | yes |
| 862 | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | yes |
| 1129 | raM_Tec_LimitWithModulo (38 members, 120 B) | raM_Tec_LimitWithModulo (38 members, 120 B) | yes |
| 1144 | PartStatus (8 members, 12 B) | PartStatus (8 members, 12 B) | yes |
| 1166 | AB:5000_DI16:C:0 (18 members, 72 B) | AB:5000_DI16:C:0 (18 members, 72 B) | yes |
| 1192 | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | yes |
| 1206 | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | yes |
| 1289 | TimeString (12 members, 376 B) | TimeString (12 members, 376 B) | yes |
| 1313 | raM_Dvc_5000AO4 (73 members, 164 B) | raM_Dvc_5000AO4 (73 members, 164 B) | yes |
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
Standard Output Messages:
Native Forward Opens over the run: 1.
Failed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [2 ms]
Error Message:
Shouldly.ShouldAssertException : unexpected
should be empty but had
2
items and was
["WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)", "Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])"]
Stack Trace:
at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports() in /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs:line 123
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
--- End of stack trace from previous location ---
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
Standard Output Messages:
## Reads — 25 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
Selection by origin: array 9, udt member 8, scalar 8
| Tag | Type | libplctag status | native status | Equal? |
|---|---|---|---|---|
| `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | yes |
| `Local:1:I.InfoBits` | SInt | Ok | Ok | yes |
| `__CONTAINER` | DInt | Ok | Ok | yes |
| `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | yes |
| `Local:1:I.RunMode` | Bool | Ok | Ok | yes |
| `WW_M4ActualTemp` | Real | Ok | Ok | **NO: raw** |
| `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | yes |
| `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | yes |
| `WW_P1_Status_Msg` | DInt | Ok | Ok | yes |
| `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **NO: raw** |
| `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | yes |
| `DI_CycleStart_PB` | Bool | Ok | Ok | yes |
| `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | yes |
| `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | yes |
| `test` | DInt | Ok | Ok | yes |
| `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | yes |
| `Local:1:I.Pad1` | Int | NotFound | NotFound | yes |
| `UN01_EventsPTR` | DInt | Ok | Ok | yes |
| `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | yes |
| `Local:2:I.InfoBits` | SInt | Ok | Ok | yes |
| `Sts_Vacuum_Rerun` | Bool | Ok | Ok | yes |
| `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | yes |
| `Local:2:I.RunMode` | Bool | Ok | Ok | yes |
| `Ons_VacuumRerun` | Bool | Ok | Ok | yes |
| `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | yes |
Unexpected divergences: 2; expected divergences: 0.
- UNEXPECTED: WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)
- UNEXPECTED: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
Standard Output Messages:
TCP connections to the target: baseline 0, before native 0, libplctag drain wait 5284 ms.
Test Run Failed.
Total tests: 14
Passed: 12
Failed: 2
Total time: 1.2601 Minutes
@@ -0,0 +1,369 @@
Test run for /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.1 (arm64)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.0.2+dd36e86129 (64-bit .NET 10.0.5)
[xUnit.net 00:00:00.46] Discovering: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
[xUnit.net 00:00:00.70] Discovered: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
[xUnit.net 00:00:00.93] Starting: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [26 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [17 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [4 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
Standard Output Messages:
libplctag connects before the abort: 1
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [15 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [3 m 2 s]
Standard Output Messages:
## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
Tags per pass: 95 (100 distinct); passes per transport: 50; gap between passes: 200 ms.
Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
Native requests spent on setup (one read per tag): 95; native Forward Opens: 1; native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
|---|---|---|---|---|---|---|---|
| libplctag | 50 | 107.0 | 139.5 | 57.9 | 164.8 | n/a | 0 |
| native | 50 | 100.3 | 105.8 | 94.8 | 107.5 | 2 | 0 |
Criterion: native p95 105.8 ms ≤ libplctag p95 139.5 ms × 1.10 = 153.4 ms → PASS
| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |
|---|---|---|---|---|---|---|
| 1 | 104.8 | 0/0/0 | 100.3 | 0/0/0 | 2 | 44.9/49.6/54.4 |
| 2 | 57.9 | 0/0/0 | 100.5 | 0/0/0 | 2 | 47.0/50.1/53.3 |
| 3 | 139.5 | 0/0/0 | 105.8 | 0/0/0 | 2 | 45.3/52.9/60.4 |
| 4 | 104.5 | 0/0/0 | 100.6 | 1/0/0 | 2 | 47.3/50.2/53.2 |
| 5 | 108.0 | 0/0/0 | 105.3 | 0/0/0 | 2 | 44.5/52.6/60.6 |
| 6 | 104.4 | 0/0/0 | 105.6 | 0/0/0 | 2 | 44.2/52.7/61.2 |
| 7 | 60.8 | 0/0/0 | 98.7 | 0/0/0 | 2 | 44.3/49.3/54.2 |
| 8 | 59.6 | 0/0/0 | 102.6 | 0/0/0 | 2 | 47.4/51.2/55.0 |
| 9 | 109.2 | 0/0/0 | 97.0 | 0/0/0 | 2 | 45.9/48.4/50.9 |
| 10 | 109.7 | 0/0/0 | 99.3 | 0/0/0 | 2 | 45.9/49.6/53.3 |
| 11 | 105.9 | 0/0/0 | 103.0 | 0/0/0 | 2 | 46.2/51.4/56.7 |
| 12 | 107.7 | 0/0/0 | 95.1 | 0/0/0 | 2 | 43.3/47.5/51.7 |
| 13 | 104.7 | 0/0/0 | 104.0 | 0/0/0 | 2 | 45.7/52.0/58.2 |
| 14 | 106.8 | 0/0/0 | 96.5 | 0/0/0 | 2 | 43.6/48.2/52.7 |
| 15 | 107.2 | 0/0/0 | 102.4 | 0/0/0 | 2 | 46.1/51.1/56.2 |
| 16 | 105.5 | 0/0/0 | 97.4 | 0/0/0 | 2 | 44.7/48.6/52.6 |
| 17 | 105.9 | 0/0/0 | 107.5 | 0/0/0 | 2 | 51.0/53.7/56.4 |
| 18 | 103.2 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.0/48.2/53.5 |
| 19 | 106.5 | 0/0/0 | 102.1 | 0/0/0 | 2 | 45.5/51.0/56.4 |
| 20 | 106.8 | 0/0/0 | 99.6 | 0/0/0 | 2 | 44.6/49.7/54.9 |
| 21 | 122.8 | 0/0/0 | 104.5 | 0/0/0 | 2 | 52.1/52.2/52.3 |
| 22 | 109.8 | 0/0/0 | 104.3 | 0/0/0 | 2 | 45.8/52.1/58.5 |
| 23 | 104.3 | 0/0/0 | 98.6 | 0/0/0 | 2 | 45.4/49.2/53.0 |
| 24 | 109.7 | 0/0/0 | 98.8 | 0/0/0 | 2 | 45.9/49.3/52.8 |
| 25 | 155.1 | 0/0/0 | 101.0 | 0/0/0 | 2 | 45.5/50.4/55.4 |
| 26 | 164.8 | 0/0/0 | 102.9 | 0/0/0 | 2 | 43.9/51.4/58.8 |
| 27 | 120.4 | 0/0/0 | 101.4 | 0/0/0 | 2 | 48.2/50.7/53.1 |
| 28 | 108.6 | 0/0/0 | 101.7 | 0/0/0 | 2 | 46.9/50.8/54.7 |
| 29 | 106.0 | 0/0/0 | 98.5 | 0/0/0 | 2 | 45.5/49.2/52.9 |
| 30 | 102.9 | 0/0/0 | 94.9 | 0/0/0 | 2 | 43.6/47.4/51.2 |
| 31 | 111.3 | 0/0/0 | 99.9 | 0/0/0 | 2 | 45.6/49.9/54.2 |
| 32 | 103.3 | 0/0/0 | 96.4 | 0/0/0 | 2 | 43.6/48.1/52.6 |
| 33 | 105.8 | 0/0/0 | 104.6 | 0/0/0 | 2 | 46.4/52.2/58.0 |
| 34 | 109.3 | 0/0/0 | 95.8 | 0/0/0 | 2 | 43.2/47.8/52.4 |
| 35 | 110.2 | 0/0/0 | 102.1 | 0/0/0 | 2 | 46.3/51.0/55.7 |
| 36 | 108.0 | 0/0/0 | 101.7 | 0/0/0 | 2 | 47.5/50.8/54.0 |
| 37 | 107.6 | 0/0/0 | 96.7 | 0/0/0 | 2 | 45.0/48.3/51.5 |
| 38 | 108.9 | 0/0/0 | 96.5 | 0/0/0 | 2 | 44.7/48.2/51.6 |
| 39 | 105.2 | 0/0/0 | 98.2 | 0/0/0 | 2 | 45.6/49.0/52.5 |
| 40 | 106.7 | 0/0/0 | 99.4 | 0/0/0 | 2 | 44.4/49.6/54.9 |
| 41 | 107.0 | 0/0/0 | 101.8 | 0/0/0 | 2 | 46.9/50.8/54.8 |
| 42 | 108.6 | 0/0/0 | 106.7 | 0/0/0 | 2 | 49.2/53.3/57.4 |
| 43 | 107.0 | 0/0/0 | 102.6 | 0/0/0 | 2 | 49.9/51.2/52.6 |
| 44 | 107.7 | 0/0/0 | 100.3 | 0/0/0 | 2 | 47.4/50.1/52.7 |
| 45 | 109.5 | 0/0/0 | 102.6 | 0/0/0 | 2 | 45.8/51.2/56.7 |
| 46 | 107.6 | 0/0/0 | 104.0 | 0/0/0 | 2 | 48.4/52.0/55.5 |
| 47 | 115.6 | 0/0/0 | 99.0 | 0/0/0 | 2 | 45.9/49.4/52.9 |
| 48 | 106.0 | 0/0/0 | 97.8 | 0/0/0 | 2 | 44.9/48.8/52.8 |
| 49 | 104.5 | 0/0/0 | 98.4 | 0/0/0 | 2 | 46.1/49.1/52.2 |
| 50 | 112.5 | 0/0/0 | 94.8 | 1/1/1 | 2 | 43.0/47.3/51.7 |
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_closes_its_one_connection_with_exactly_one_forward_close [< 1 ms]
Standard Output Messages:
Native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.No_failed_libplctag_tag_is_left_with_a_live_native_callback [< 1 ms]
Standard Output Messages:
libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.A_live_value_is_compared_structurally_and_a_stable_one_by_value [3 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [36 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
Standard Output Messages:
## Browse — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
libplctag: 3162 tag(s); native: 3162 tag(s)
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
Standard Output Messages:
## Templates — 20 id(s); truncated by AB_DIFF_MAX_TEMPLATES: libplctag 74, native 74
| Template | libplctag | native | Equal? |
|---|---|---|---|
| 283 | Recipe (5 members, 1680 B) | Recipe (5 members, 1680 B) | yes |
| 295 | UDT_PartStatus (5 members, 4 B) | UDT_PartStatus (5 members, 4 B) | yes |
| 344 | Recipe_Clamp (7 members, 420 B) | Recipe_Clamp (7 members, 420 B) | yes |
| 491 | ForceSegmentEnableCheck (3 members, 4 B) | ForceSegmentEnableCheck (3 members, 4 B) | yes |
| 527 | Recipe_PowerUnit (6 members, 24 B) | Recipe_PowerUnit (6 members, 24 B) | yes |
| 622 | UDT_TemperatureProfile_Segment (11 members, 40 B) | UDT_TemperatureProfile_Segment (11 members, 40 B) | yes |
| 676 | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | yes |
| 700 | AB:5000_AI4CJ:I:0 (12 members, 68 B) | AB:5000_AI4CJ:I:0 (12 members, 68 B) | yes |
| 706 | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | yes |
| 715 | TwoHand_Control (8 members, 16 B) | TwoHand_Control (8 members, 16 B) | yes |
| 752 | STR0016 (2 members, 20 B) | STR0016 (2 members, 20 B) | yes |
| 842 | STR0032 (2 members, 36 B) | STR0032 (2 members, 36 B) | yes |
| 862 | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | yes |
| 1129 | raM_Tec_LimitWithModulo (38 members, 120 B) | raM_Tec_LimitWithModulo (38 members, 120 B) | yes |
| 1144 | PartStatus (8 members, 12 B) | PartStatus (8 members, 12 B) | yes |
| 1166 | AB:5000_DI16:C:0 (18 members, 72 B) | AB:5000_DI16:C:0 (18 members, 72 B) | yes |
| 1192 | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | yes |
| 1206 | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | yes |
| 1289 | TimeString (12 members, 376 B) | TimeString (12 members, 376 B) | yes |
| 1313 | raM_Dvc_5000AO4 (73 members, 164 B) | raM_Dvc_5000AO4 (73 members, 164 B) | yes |
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
Standard Output Messages:
Native Forward Opens over the run: 1.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [3 ms]
Standard Output Messages:
## Reads — 200 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
Selection by origin: array 20, udt member 20, scalar 160
| Tag | Type | libplctag status | native status | libplctag probe | Equal? |
|---|---|---|---|---|---|
| `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | stable | yes |
| `Local:1:I.InfoBits` | SInt | Ok | Ok | stable | yes |
| `__CONTAINER` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | stable | yes |
| `Local:1:I.RunMode` | Bool | Ok | Ok | stable | yes |
| `WW_M4ActualTemp` | Real | Ok | Ok | **live** | live |
| `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | stable | yes |
| `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
| `WW_P1_Status_Msg` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **live** | live |
| `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
| `DI_CycleStart_PB` | Bool | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | stable | yes |
| `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
| `test` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | stable | yes |
| `Local:1:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
| `UN01_EventsPTR` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
| `Local:2:I.InfoBits` | SInt | Ok | Ok | stable | yes |
| `Sts_Vacuum_Rerun` | Bool | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | stable | yes |
| `Local:2:I.RunMode` | Bool | Ok | Ok | stable | yes |
| `Ons_VacuumRerun` | Bool | Ok | Ok | stable | yes |
| `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
| `Local:2:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
| `test2` | DInt | Ok | Ok | stable | yes |
| `Program:EM02_Clamp.__fout79B2713C88B35A28` | Real[3] | Ok | Ok | stable | yes |
| `Local:2:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
| `_Val_PressurePVx` | Real | Ok | Ok | stable | yes |
| `Program:EM02_Clamp.__fout2F67CD35C2302EE0` | Real[4] | Ok | Ok | **live** | live |
| `Local:2:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
| `C_LimitType_Outside` | DInt | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB` | Real[4] | Ok | Ok | **live** | live |
| `Local:2:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
| `Par_TargetTemperature_Tolerance` | Real | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout92848E133B44AF24` | Real[8] | Ok | Ok | **live** | live |
| `Local:3:I.InfoBits` | SInt | Ok | Ok | stable | yes |
| `Cfg_RapidHeatup_Tolerancex` | DInt | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout6DC7745628658A9D` | Real[2] | Ok | Ok | **live** | live |
| `Local:3:I.RunMode` | Bool | Ok | Ok | stable | yes |
| `Cfg_RapidHeatup_Tolerance` | Real | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout7C6F52924950A71D` | Real[2] | Ok | Ok | stable | yes |
| `Local:3:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
| `test_stop` | Bool | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__foutCBB7A04F2668DCD0` | Real[2] | Ok | Ok | stable | yes |
| `Local:3:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
| `Sts_ScreenLock` | Bool | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._DisableStates` | DInt[32] | Ok | Ok | stable | yes |
| `Local:3:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
| `xSts_ClearResult` | Bool | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._ModeNames` | String[32] | Ok | Ok | stable | yes |
| `Local:3:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
| `WS2_CLOSE_Delay_Setpoint` | Int | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._ModeTransitions` | DInt[32] | Ok | Ok | stable | yes |
| `Local:5:C.CfgSize` | DInt | NotFound | NotFound | NotFound | yes |
| `WS2_Force_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._SEQ_UP` | DInt[32] | Ok | Ok | stable | yes |
| `Local:5:C.CfgIDNum` | DInt | NotFound | NotFound | NotFound | yes |
| `WS2_Force_Red_Setpoint` | Real | Ok | Ok | stable | yes |
| `C_LimitType_Inside` | DInt | Ok | Ok | stable | yes |
| `xxCfg_WaterValve_MinTime` | Real | Ok | Ok | stable | yes |
| `WS2_Force_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_Force_Yellow` | Real | Ok | Ok | stable | yes |
| `WS2_F_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WW_Green` | Bool | Ok | Ok | stable | yes |
| `WW_Down` | Bool | Ok | Ok | stable | yes |
| `WS2_F_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_HighMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_LowMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_Mold_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_Mold_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_POSITION_SP` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG1` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG10` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG2` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG3` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG4` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG5` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG6` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG7` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG8` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG9` | Real | Ok | Ok | stable | yes |
| `WS2_SPEED_SP` | Real | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
Unexpected divergences: 0; expected divergences: 0; live tags (value moved between the two libplctag reads; compared by status, length, type code, shape and every element that held still): 6 of 200.
- live: WW_M4ActualTemp (lib 4 byte(s) 0CCDC243 = 389.60193; lib probe 4 byte(s) 7084C243 = 389.03467; native 4 byte(s) 4C47C243 = 388.557)
- live: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5 (lib 20 byte(s) 0050C348218986402189A640DF76D9C021898640 = [400000,4.2042394,5.2042394,-6.7957606,4.2042394]; lib probe 20 byte(s) 0050C348E74B8640E74BA64019B4D9C0E74B8640 = [400000,4.1967654,5.1967654,-6.8032346,4.1967654]; native 20 byte(s) 0050C348BB328640BB32A64045CDD9C0BB328640 = [400000,4.1936927,5.1936927,-6.8063073,4.1936927])
- live: Program:EM02_Clamp.__fout2F67CD35C2302EE0 (lib 16 byte(s) BFB2BF379A9919BF00D68349CDCCCCBE = [2.285221E-05,-0.6,1080000,-0.4]; lib probe 16 byte(s) 5119313A9A9919BF00D68349CDCCCCBE = [0.00067557866,-0.6,1080000,-0.4]; native 16 byte(s) 1562C2B99A9919BF00D68349CDCCCCBE = [-0.0003707564,-0.6,1080000,-0.4])
- live: Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB (lib 16 byte(s) 00E14CC0CDDA75BF000000C08F2D2BC0 = [-3.201233,-0.9603699,-2,-2.6746557]; lib probe 16 byte(s) 009464C09A2589BF000000C0A84932C0 = [-3.5715332,-1.07146,-2,-2.7857456]; native 16 byte(s) 00A03AC034F35FBF000000C0A8B325C0 = [-2.9160156,-0.87480474,-2,-2.5890903])
- live: Program:ES03_EM01_Zone1.__fout92848E133B44AF24 (lib 32 byte(s) 40EDA94160F0D043000090C10022A14980256C41C656D143006A4F400000A8C1 = [21.240845,417.87793,-18,1320000,14.759155,418.67792,3.2408447,-21]; lib probe 32 byte(s) 6017AD412089D043000090C10022A14940D1654186EFD04300BB68400000A8C1 = [21.636414,417.0713,-18,1320000,14.363586,417.87128,3.6364136,-21]; native 32 byte(s) C047A841BE97CF43000090C10022A14980706F4124FECF43003E42400000A8C1 = [21.035034,415.1855,-18,1320000,14.964966,415.98547,3.0350342,-21])
- live: Program:ES03_EM01_Zone1.__fout6DC7745628658A9D (lib 8 byte(s) 00824E40CD34A53F = [3.2266846,1.2906739]; lib probe 8 byte(s) 008A6440CDD4B63F = [3.5709229,1.4283692]; native 8 byte(s) 000D3940670A943F = [2.8914185,1.1565675])
[xUnit.net 00:03:12.61] Finished: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
Standard Output Messages:
TCP connections to the target: baseline 0, before native 0, libplctag drain wait 1 ms.
Test Run Successful.
Total tests: 17
Passed: 17
Total time: 3.2143 Minutes
@@ -0,0 +1,369 @@
Test run for /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.1 (arm64)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.0.2+dd36e86129 (64-bit .NET 10.0.5)
[xUnit.net 00:00:00.35] Discovering: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
[xUnit.net 00:00:00.49] Discovered: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
[xUnit.net 00:00:00.60] Starting: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [9 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [7 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [2 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
Standard Output Messages:
libplctag connects before the abort: 1
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [13 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [2 m 56 s]
Standard Output Messages:
## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
Tags per pass: 95 (100 distinct); passes per transport: 50; gap between passes: 200 ms.
Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
Native requests spent on setup (one read per tag): 95; native Forward Opens: 1; native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
|---|---|---|---|---|---|---|---|
| libplctag | 50 | 101.8 | 197.3 | 54.6 | 219.2 | n/a | 0 |
| native | 50 | 99.4 | 105.1 | 76.0 | 139.3 | 2 | 0 |
Criterion: native p95 105.1 ms ≤ libplctag p95 197.3 ms × 1.10 = 217.1 ms → PASS
| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |
|---|---|---|---|---|---|---|
| 1 | 99.9 | 0/0/0 | 99.6 | 0/0/0 | 2 | 45.0/49.4/53.8 |
| 2 | 101.7 | 0/0/0 | 101.3 | 0/0/0 | 2 | 48.1/50.5/53.0 |
| 3 | 100.0 | 0/0/0 | 99.6 | 0/0/0 | 2 | 45.7/49.7/53.7 |
| 4 | 102.4 | 0/0/0 | 98.9 | 1/0/0 | 2 | 48.2/49.4/50.5 |
| 5 | 105.8 | 0/0/0 | 97.1 | 0/0/0 | 2 | 44.8/48.5/52.2 |
| 6 | 103.5 | 0/0/0 | 99.8 | 0/0/0 | 2 | 45.3/49.8/54.3 |
| 7 | 100.5 | 0/0/0 | 105.1 | 0/0/0 | 2 | 51.2/52.5/53.8 |
| 8 | 105.7 | 0/0/0 | 94.4 | 0/0/0 | 2 | 43.3/47.1/50.9 |
| 9 | 99.8 | 0/0/0 | 99.1 | 0/0/0 | 2 | 47.0/49.5/51.9 |
| 10 | 102.8 | 0/0/0 | 98.1 | 0/0/0 | 2 | 48.4/49.0/49.5 |
| 11 | 54.6 | 0/0/0 | 98.7 | 0/0/0 | 2 | 44.0/49.3/54.5 |
| 12 | 99.8 | 0/0/0 | 105.2 | 0/0/0 | 2 | 44.6/52.5/60.5 |
| 13 | 101.5 | 0/0/0 | 94.9 | 0/0/0 | 2 | 43.7/47.4/51.0 |
| 14 | 109.8 | 0/0/0 | 98.9 | 0/0/0 | 2 | 46.9/49.4/51.8 |
| 15 | 106.7 | 0/0/0 | 96.3 | 0/0/0 | 2 | 43.2/48.0/52.8 |
| 16 | 165.0 | 0/0/0 | 99.4 | 0/0/0 | 2 | 46.6/49.6/52.5 |
| 17 | 197.3 | 0/0/0 | 101.3 | 0/0/0 | 2 | 45.7/50.6/55.4 |
| 18 | 71.0 | 0/0/0 | 98.8 | 0/0/0 | 2 | 45.3/49.3/53.4 |
| 19 | 219.2 | 0/0/0 | 101.0 | 0/0/0 | 2 | 48.0/50.4/52.8 |
| 20 | 168.8 | 0/0/0 | 100.5 | 0/0/0 | 2 | 46.2/50.1/54.1 |
| 21 | 95.3 | 0/0/0 | 98.3 | 0/0/0 | 2 | 45.7/49.0/52.4 |
| 22 | 133.9 | 0/0/0 | 98.9 | 0/0/0 | 2 | 46.4/49.4/52.3 |
| 23 | 163.0 | 0/0/0 | 97.2 | 0/0/0 | 2 | 44.9/48.5/52.1 |
| 24 | 184.5 | 0/0/0 | 101.4 | 0/0/0 | 2 | 49.1/50.6/52.2 |
| 25 | 130.9 | 0/0/0 | 97.2 | 0/0/0 | 2 | 43.7/48.5/53.3 |
| 26 | 76.8 | 0/0/0 | 103.2 | 0/0/0 | 2 | 47.3/51.5/55.8 |
| 27 | 99.6 | 0/0/0 | 76.0 | 0/0/0 | 1 | 75.8/75.8/75.8 |
| 28 | 113.8 | 0/0/0 | 100.3 | 0/0/0 | 2 | 46.4/50.1/53.7 |
| 29 | 100.9 | 0/0/0 | 96.0 | 0/0/0 | 2 | 44.2/47.9/51.6 |
| 30 | 106.0 | 0/0/0 | 103.0 | 0/0/0 | 2 | 49.9/51.4/53.0 |
| 31 | 107.3 | 0/0/0 | 103.7 | 0/0/0 | 2 | 47.4/51.8/56.1 |
| 32 | 100.9 | 0/0/0 | 102.4 | 0/0/0 | 2 | 47.4/51.1/54.8 |
| 33 | 99.9 | 0/0/0 | 98.6 | 0/0/0 | 2 | 46.0/49.2/52.4 |
| 34 | 101.4 | 0/0/0 | 98.7 | 0/0/0 | 2 | 46.7/49.3/51.8 |
| 35 | 97.8 | 0/0/0 | 98.9 | 0/0/0 | 2 | 46.3/49.4/52.5 |
| 36 | 98.7 | 0/0/0 | 98.5 | 0/0/0 | 2 | 45.2/49.2/53.2 |
| 37 | 101.2 | 0/0/0 | 100.1 | 0/0/0 | 2 | 45.2/49.9/54.7 |
| 38 | 102.6 | 0/0/0 | 100.0 | 0/0/0 | 2 | 44.0/49.9/55.8 |
| 39 | 103.5 | 0/0/0 | 97.7 | 0/0/0 | 2 | 45.0/48.8/52.5 |
| 40 | 104.5 | 0/0/0 | 99.6 | 0/0/0 | 2 | 46.0/49.7/53.5 |
| 41 | 103.0 | 0/0/0 | 99.4 | 0/0/0 | 2 | 45.4/49.6/53.9 |
| 42 | 101.8 | 0/0/0 | 98.7 | 0/0/0 | 2 | 46.0/49.2/52.4 |
| 43 | 101.8 | 0/0/0 | 139.3 | 0/0/0 | 2 | 45.6/69.6/93.6 |
| 44 | 109.4 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.2/48.2/53.3 |
| 45 | 101.6 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.2/48.2/53.2 |
| 46 | 101.6 | 0/0/0 | 102.8 | 0/0/0 | 2 | 47.9/51.3/54.8 |
| 47 | 204.5 | 0/0/0 | 101.0 | 0/0/0 | 2 | 47.3/50.4/53.5 |
| 48 | 108.0 | 0/0/0 | 100.0 | 0/0/0 | 2 | 47.4/49.9/52.4 |
| 49 | 99.7 | 0/0/0 | 101.3 | 0/0/0 | 2 | 48.3/50.6/52.8 |
| 50 | 99.7 | 0/0/0 | 99.4 | 1/1/1 | 2 | 46.0/49.6/53.2 |
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_closes_its_one_connection_with_exactly_one_forward_close [< 1 ms]
Standard Output Messages:
Native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.No_failed_libplctag_tag_is_left_with_a_live_native_callback [< 1 ms]
Standard Output Messages:
libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.A_live_value_is_compared_structurally_and_a_stable_one_by_value [3 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [36 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
Standard Output Messages:
## Browse — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
libplctag: 3162 tag(s); native: 3162 tag(s)
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
Standard Output Messages:
## Templates — 20 id(s); truncated by AB_DIFF_MAX_TEMPLATES: libplctag 74, native 74
| Template | libplctag | native | Equal? |
|---|---|---|---|
| 283 | Recipe (5 members, 1680 B) | Recipe (5 members, 1680 B) | yes |
| 295 | UDT_PartStatus (5 members, 4 B) | UDT_PartStatus (5 members, 4 B) | yes |
| 344 | Recipe_Clamp (7 members, 420 B) | Recipe_Clamp (7 members, 420 B) | yes |
| 491 | ForceSegmentEnableCheck (3 members, 4 B) | ForceSegmentEnableCheck (3 members, 4 B) | yes |
| 527 | Recipe_PowerUnit (6 members, 24 B) | Recipe_PowerUnit (6 members, 24 B) | yes |
| 622 | UDT_TemperatureProfile_Segment (11 members, 40 B) | UDT_TemperatureProfile_Segment (11 members, 40 B) | yes |
| 676 | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | yes |
| 700 | AB:5000_AI4CJ:I:0 (12 members, 68 B) | AB:5000_AI4CJ:I:0 (12 members, 68 B) | yes |
| 706 | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | yes |
| 715 | TwoHand_Control (8 members, 16 B) | TwoHand_Control (8 members, 16 B) | yes |
| 752 | STR0016 (2 members, 20 B) | STR0016 (2 members, 20 B) | yes |
| 842 | STR0032 (2 members, 36 B) | STR0032 (2 members, 36 B) | yes |
| 862 | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | yes |
| 1129 | raM_Tec_LimitWithModulo (38 members, 120 B) | raM_Tec_LimitWithModulo (38 members, 120 B) | yes |
| 1144 | PartStatus (8 members, 12 B) | PartStatus (8 members, 12 B) | yes |
| 1166 | AB:5000_DI16:C:0 (18 members, 72 B) | AB:5000_DI16:C:0 (18 members, 72 B) | yes |
| 1192 | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | yes |
| 1206 | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | yes |
| 1289 | TimeString (12 members, 376 B) | TimeString (12 members, 376 B) | yes |
| 1313 | raM_Dvc_5000AO4 (73 members, 164 B) | raM_Dvc_5000AO4 (73 members, 164 B) | yes |
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
Standard Output Messages:
Native Forward Opens over the run: 1.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [3 ms]
Standard Output Messages:
## Reads — 200 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
Selection by origin: array 20, udt member 20, scalar 160
| Tag | Type | libplctag status | native status | libplctag probe | Equal? |
|---|---|---|---|---|---|
| `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | stable | yes |
| `Local:1:I.InfoBits` | SInt | Ok | Ok | stable | yes |
| `__CONTAINER` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | stable | yes |
| `Local:1:I.RunMode` | Bool | Ok | Ok | stable | yes |
| `WW_M4ActualTemp` | Real | Ok | Ok | **live** | live |
| `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | stable | yes |
| `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
| `WW_P1_Status_Msg` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **live** | live |
| `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
| `DI_CycleStart_PB` | Bool | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | stable | yes |
| `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
| `test` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | stable | yes |
| `Local:1:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
| `UN01_EventsPTR` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
| `Local:2:I.InfoBits` | SInt | Ok | Ok | stable | yes |
| `Sts_Vacuum_Rerun` | Bool | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | stable | yes |
| `Local:2:I.RunMode` | Bool | Ok | Ok | stable | yes |
| `Ons_VacuumRerun` | Bool | Ok | Ok | stable | yes |
| `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
| `Local:2:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
| `test2` | DInt | Ok | Ok | stable | yes |
| `Program:EM02_Clamp.__fout79B2713C88B35A28` | Real[3] | Ok | Ok | stable | yes |
| `Local:2:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
| `_Val_PressurePVx` | Real | Ok | Ok | stable | yes |
| `Program:EM02_Clamp.__fout2F67CD35C2302EE0` | Real[4] | Ok | Ok | **live** | live |
| `Local:2:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
| `C_LimitType_Outside` | DInt | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB` | Real[4] | Ok | Ok | **live** | live |
| `Local:2:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
| `Par_TargetTemperature_Tolerance` | Real | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout92848E133B44AF24` | Real[8] | Ok | Ok | **live** | live |
| `Local:3:I.InfoBits` | SInt | Ok | Ok | stable | yes |
| `Cfg_RapidHeatup_Tolerancex` | DInt | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout6DC7745628658A9D` | Real[2] | Ok | Ok | **live** | live |
| `Local:3:I.RunMode` | Bool | Ok | Ok | stable | yes |
| `Cfg_RapidHeatup_Tolerance` | Real | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout7C6F52924950A71D` | Real[2] | Ok | Ok | stable | yes |
| `Local:3:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
| `test_stop` | Bool | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__foutCBB7A04F2668DCD0` | Real[2] | Ok | Ok | stable | yes |
| `Local:3:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
| `Sts_ScreenLock` | Bool | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._DisableStates` | DInt[32] | Ok | Ok | stable | yes |
| `Local:3:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
| `xSts_ClearResult` | Bool | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._ModeNames` | String[32] | Ok | Ok | stable | yes |
| `Local:3:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
| `WS2_CLOSE_Delay_Setpoint` | Int | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._ModeTransitions` | DInt[32] | Ok | Ok | stable | yes |
| `Local:5:C.CfgSize` | DInt | NotFound | NotFound | NotFound | yes |
| `WS2_Force_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._SEQ_UP` | DInt[32] | Ok | Ok | stable | yes |
| `Local:5:C.CfgIDNum` | DInt | NotFound | NotFound | NotFound | yes |
| `WS2_Force_Red_Setpoint` | Real | Ok | Ok | stable | yes |
| `C_LimitType_Inside` | DInt | Ok | Ok | stable | yes |
| `xxCfg_WaterValve_MinTime` | Real | Ok | Ok | stable | yes |
| `WS2_Force_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_Force_Yellow` | Real | Ok | Ok | stable | yes |
| `WS2_F_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WW_Green` | Bool | Ok | Ok | stable | yes |
| `WW_Down` | Bool | Ok | Ok | stable | yes |
| `WS2_F_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_HighMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_LowMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_Mold_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_Mold_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_POSITION_SP` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG1` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG10` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG2` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG3` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG4` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG5` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG6` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG7` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG8` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG9` | Real | Ok | Ok | stable | yes |
| `WS2_SPEED_SP` | Real | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
Unexpected divergences: 0; expected divergences: 0; live tags (value moved between the two libplctag reads; compared by status, length, type code, shape and every element that held still): 6 of 200.
- live: WW_M4ActualTemp (lib 4 byte(s) 20526943 = 233.3208; lib probe 4 byte(s) 908D6843 = 232.55298; native 4 byte(s) 501F6743 = 231.12231)
- live: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5 (lib 20 byte(s) 0050C3487C144C403E0A8640C2F5F9C07C144C40 = [400000,3.1887503,4.1887503,-7.8112497,3.1887503]; lib probe 20 byte(s) 0050C3481C244A400E128540F2EDFAC01C244A40 = [400000,3.158454,4.158454,-7.841546,3.158454]; native 20 byte(s) 0050C34831AA454018D58240E82AFDC031AA4540 = [400000,3.0885127,4.0885124,-7.9114876,3.0885127])
- live: Program:EM02_Clamp.__fout2F67CD35C2302EE0 (lib 16 byte(s) 96D41EB99A9919BF00D68349CDCCCCBE = [-0.00015147249,-0.6,1080000,-0.4]; lib probe 16 byte(s) A830FE389A9919BF00D68349CDCCCCBE = [0.00012120727,-0.6,1080000,-0.4]; native 16 byte(s) D34D8FB99A9919BF00D68349CDCCCCBE = [-0.0002733307,-0.6,1080000,-0.4])
- live: Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB (lib 16 byte(s) 11E416C300000000000000C000000000 = [-150.89088,0,-2,0]; lib probe 16 byte(s) F1E910C300000000000000C000000000 = [-144.91383,0,-2,0]; native 16 byte(s) 194008C300000000000000C000000000 = [-136.25038,0,-2,0])
- live: Program:ES03_EM01_Zone1.__fout92848E133B44AF24 (lib 32 byte(s) 91D52843C4085243000090C10022A14991D504C391D5524391D516430000A8C1 = [168.83424,210.03424,-18,1320000,-132.83424,210.83424,150.83424,-21]; lib probe 32 byte(s) CDD7224398074C43000090C10022A1499AAFFDC265D44C43CDD710430000A8C1 = [162.84297,204.02966,-18,1320000,-126.84297,204.82967,144.84297,-21]; native 32 byte(s) 2D311A4388634343000090C10022A1495A62ECC2553044432D3108430000A8C1 = [154.1921,195.3888,-18,1320000,-118.19209,196.1888,136.1921,-21])
- live: Program:ES03_EM01_Zone1.__fout6DC7745628658A9D (lib 8 byte(s) 4DC71643E23E7142 = [150.77852,60.31141]; lib probe 8 byte(s) F5BA104388916742 = [144.7303,57.89212]; native 8 byte(s) 650B0843D5AB5942 = [136.04451,54.417805])
[xUnit.net 00:03:06.03] Finished: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
Standard Output Messages:
TCP connections to the target: baseline 0, before native 0, libplctag drain wait 1 ms.
Test Run Successful.
Total tests: 17
Passed: 17
Total time: 3.1046 Minutes
@@ -175,7 +175,7 @@
{
"id": 19,
"subject": "Task 19: AdminUI `Transport` dropdown (AbCip + AbLegacy) + d",
"status": "pending",
"status": "completed",
"description": "**Goal:** An operator can pick the transport per driver in the `/raw` driver-config modal, the value round-trips as a string, and a live docker-dev run proves it reaches the driver.\n\n**Files:**\n- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor` (`FormModel.Transport`, `ToOptions`/`FromOptions`, an `InputSelect` in the advanced section)\n- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor` (same)\n- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs` (add Transport round-trip cases)\n- Create or modify: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs` (exists \u21d2 add cases; absent \u21d2 create, modelled on the AbLegacy file)\n\n**Acceptance Criteria:**\n- [ ] The dropdown has options \"libplctag (default)\" \u21d2 `Libplctag` and \"Native (managed)\" \u21d2 `Native`, bound with `@bind-Value:after=\"EmitAsync\"` the way the other fields are.\n- [ ] The serialized JSON contains `\"transport\":\"Native\"` (a string, not a number). It goes through the existing `_jsonOpts` with `JsonStringEnumConverter` (`AbCipDriverForm.razor:108-113`); verify the AbLegacy form has the same converter and add it if missing.\n- [ ] Loading a config without `transport` shows \"libplctag (default)\" and re-emits **without** a `transport` key, or with `\"Libplctag\"`; either is fine, but pick one and test it.\n- [ ] **Live `/run` on docker-dev** (login is disabled; drive it yourself with the Chrome tools against `http://localhost:9200`):\n 1. Bring up the AbCip `controllogix` fixture on `10.100.0.35`.\n 2. In `/raw`, create or edit an AbCip driver with a device `ab://10.100.0.35:44818/1,0`, set Transport = Native, and add the raw tag `TestDINT`.\n 3. Deploy.\n 4. Read the UNS/raw node with the Client CLI (`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n \"ns=<raw ns>;s=<RawPath>\"`) and get `Good`.\n 5. Confirm from the central node's logs that the native runtime served it. Add one `LogDebug(\"AbCip transport={Transport}\")` at driver init if no log line distinguishes the two.\n 6. Flip back to libplctag, redeploy, and read `Good` again.\n 7. Record the output under the gate-results doc's `## AdminUI /run (T19)` heading only.\n- [ ] **Inverted-gate check** (memory: \"an unchanged observable proves nothing\"): with Transport = Native, point the device at a port where no `ab_server` is listening. The read must go **Bad** and the log must show the *native* error text. That proves the switch changes behaviour and isn't inert.\n\n**Verify:** AdminUI unit tests pass, plus the docker-dev `/run` evidence (CLI output for both transports and the inverted case) is pasted into `docs/plans/2026-10-07-native-ab-transport-gate-results.md`.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 19.\n\n```json:metadata\n{\"files\": [\"src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor\", \"src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor\", \"tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs\", \"tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs\"], \"verifyCommand\": \"\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 5}\n```",
"blockedBy": [
17
@@ -184,7 +184,7 @@
{
"id": 20,
"subject": "Task 20: Documentation",
"status": "pending",
"status": "completed",
"description": "**Goal:** Document the native transport, the switch, the known gaps and the client-visible type-check change, in the repo and in the umbrella history.\n\n**Files:**\n- Modify: `docs/drivers/AbCip.md` (sections: \"Transport (`Transport` option)\", \"Native transport behaviour\" covering connection sharing, MSP packing, the type check and press-safety limits, and \"Known gaps\" covering simulator-only writes, PLC-5/Micro800/routed paths, and libplctag deletion blocked on a bench controller)\n- Modify: `docs/drivers/AbLegacy.md` (Transport section, PCCC simulator-only gap, PLC-5 status from Task 8, ST byte-order status from Task 14)\n- Modify: `docs/drivers/AbServer-Test-Fixture.md` and `docs/drivers/AbLegacy-Test-Fixture.md` (suites now run per transport; the capture recipe `scripts/ab-capture/capture.sh`)\n- Modify: `CLAUDE.md` (one short paragraph under a new heading \"AB drivers: native transport (`Transport`)\" with the switch, the confinement guard, and a pointer to the docs; keep it short, under 15 lines)\n- Modify: `../scadaproj/.claude/skills/sister-project-history/otopcua.md` (append a dated entry), on scadaproj's `main` per the CLAUDE.md procedure\n\n**Acceptance Criteria:**\n- [ ] `docs/drivers/AbCip.md` states explicitly:\n - The native path reports `BadTypeMismatch` for a same-width mis-authored type that libplctag reads as Good garbage, with an example (DINT authored, REAL returned).\n - That is the fix working, and it is called out as client-visible.\n- [ ] Every config key added (`transport`) is documented with its values, its default and string serialization.\n- [ ] The scadaproj history entry is committed on `main` and pushed. The OtOpcUa index **row** in `../scadaproj/CLAUDE.md` is **not** touched; the summary does not change, since the driver set and wire contract are the same.\n- [ ] The scadaproj branch is restored afterwards.\n\n**Verify:** `grep -n \"Transport\" docs/drivers/AbCip.md docs/drivers/AbLegacy.md CLAUDE.md` shows the new sections; `cd ../scadaproj && git log origin/main -1 --format=%s` shows the history commit.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 20.\n\n```json:metadata\n{\"files\": [\"../scadaproj/.claude/skills/sister-project-history/otopcua.md\", \"CLAUDE.md\", \"docs/drivers/AbCip.md\", \"docs/drivers/AbLegacy-Test-Fixture.md\", \"docs/drivers/AbLegacy.md\", \"docs/drivers/AbServer-Test-Fixture.md\", \"scripts/ab-capture/capture.sh\"], \"verifyCommand\": \"grep -n \\\"Transport\\\" docs/drivers/AbCip.md docs/drivers/AbLegacy.md CLAUDE.md\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 4}\n```",
"blockedBy": [
19
@@ -193,7 +193,7 @@
{
"id": 21,
"subject": "Task 21: Real-press read-only gate (CR CompactLogix)",
"status": "pending",
"status": "completed",
"description": "**Goal:** Run the Task 18 differential harness and a latency comparison against one production CompactLogix 5069-L320ERS2 press at CR, **read-only**, and record the evidence that decides the AbCip default flip.\n\n> **USER-ORDERED GATE \u2014 NON-SKIPPABLE.** This task was requested by the user in the current conversation. It MUST NOT be closed by walking around it, by declaring it \"verified inline\", or by substituting a cheaper check. Close only after every item in `acceptanceCriteria` has been re-validated independently, with output captured.\n\n**Files:**\n- Modify: `docs/plans/2026-10-07-native-ab-transport-gate-results.md` (append the press results)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs`\n\n**Acceptance Criteria:**\n- [ ] **Preconditions (human):** the VPN to CR is up, the user has OK'd touching the press at this time, and the target is **one** press. The default is Z-35162 at `10.209.70.160:44818`, path `1,0`; any other press must be named by the user.\n- [ ] **Run the differential:**\n - `AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_DIFF_FAMILY=CompactLogix AB_DIFF_COUNT_OPENS=1 dotnet test \u2026--filter \"FullyQualifiedName~Differential\"` passes.\n - Browse parity holds: the same discovered tag set (the 2026-10-07 run saw ~23,412 variables across 80 programs) and identical UDT shapes.\n - Read parity holds over `AB_DIFF_MAX_TAGS=200`, with zero unexpected divergences. Every `TypeMismatch` expected-divergence is listed by tag name.\n - Native made exactly **1** Forward Open over the run. That is achievable only because T18's harness holds an anchor lease for the whole run; if the count is higher, the harness is broken, not the press.\n- [ ] **Latency comparison** (`PollLatencyComparisonTests`, env-gated by the same variables):\n - Read a fixed set of 100 atomic tags 30 times per transport, sequentially; transports are never concurrent. The native side runs under an anchor lease, as in T18.\n - Native passes issue all 100 reads **concurrently** (`Task.WhenAll`), so the shared scheduler can MSP-pack them. libplctag passes do the same; libplctag packs internally.\n - Record the CIP round-trips per pass (native: `CipConnection.RequestsSent` from Task 9, delta per pass; libplctag: report n/a) and the p50/p95 wall time per pass.\n - Pass criterion: native p95 \u2264 libplctag p95 \u00d7 1.10.\n- [ ] **No writes:**\n - Evidence: the Task 18 source guard passes.\n - `grep -c WriteAsync` over both differential test files returns 0.\n- [ ] **Recorded results:** under the `## Real press (T21)` and `## Latency (T21)` headings, the results doc carries the raw `dotnet test` output, the divergence table, the latency table, the Forward Open count, the date/time (UTC) of the run and the press ID.\n- [ ] **Failure handling:** if any criterion fails, the task stays open. Fix the code (with a pinning test), re-run the ab_server suites (Task 17) and the press run. **Do not** relax the criterion.\n\n**Verify:** `AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_DIFF_FAMILY=CompactLogix AB_DIFF_COUNT_OPENS=1 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --filter \"FullyQualifiedName~Differential\" --logger \"console;verbosity=detailed\"` \u2192 passed, 0 unexpected divergences, `ForwardOpenCount=1`, and native p95 \u2264 1.10 \u00d7 libplctag p95.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 21.\n\n```json:metadata\n{\"files\": [\"docs/plans/2026-10-07-native-ab-transport-gate-results.md\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs\"], \"verifyCommand\": \"AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_DIFF_FAMILY=CompactLogix AB_DIFF_COUNT_OPENS=1 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --filter \\\"FullyQualifiedName~Differential\\\" --logger \\\"console;verbosity=detailed\\\"\", \"modelTier\": \"frontier\", \"model\": \"opus\", \"userGate\": true, \"tags\": [\"user-gate\"], \"gateScope\": \"task\", \"failurePolicy\": \"keep-open-and-fix\", \"requireEvidenceTokens\": [[\"libplctag\", \"baseline\"], [\"native\"], [\"ForwardOpenCount=1\", \"ForwardOpenCount: 1\"]], \"acceptanceCriteriaCount\": 6}\n```",
"blockedBy": [
18
@@ -202,7 +202,7 @@
{
"id": 22,
"subject": "Task 22: Flip the defaults to Native + release note",
"status": "pending",
"status": "completed",
"description": "**Goal:** Make `Native` the default transport for both drivers (spec \u00a78 P5), keeping `Libplctag` selectable, and write the client-visible release note.\n\n**Files:**\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs` (default `AbCipTransport.Native`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs` (`fallback: AbCipTransport.Native`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs` (same)\n- Modify: both AdminUI forms (relabel the options as \"Native (default)\" / \"libplctag (fallback)\")\n- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs`, `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs` (the absent \u21d2 Native expectation)\n- Modify: the CLIs' `--transport` default (`native`)\n- Modify: `docs/drivers/AbCip.md`, `docs/drivers/AbLegacy.md` (the default, and a \"Release note\" subsection)\n\n**Acceptance Criteria:**\n- [ ] Blocked by Task 21 passing **and** Task 17 green for all AbLegacy profiles.\n- [ ] A config with no `transport` key runs Native in both drivers; prove it with the switch tests.\n- [ ] Existing unit tests that construct `AbCipDriver` with **no** injected factory and expect libplctag behaviour are updated to set `Transport = Libplctag` explicitly. Find them with `grep -rn \"new AbCipDriver(\" tests | grep -v tagFactory`.\n- [ ] The release note says:\n - The native transport is the default.\n - It reports `BadTypeMismatch` for mis-authored same-width types.\n - Set `\"transport\": \"Libplctag\"` to fall back.\n - AbCip writes and all of AbLegacy are simulator-proven only.\n- [ ] The full `dotnet test ZB.MOM.WW.OtOpcUa.slnx` passes, except suites that need fixtures that are not up; those are listed as skipped.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests` \u2192 all pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 22.\n\n```json:metadata\n{\"files\": [\"docs/drivers/AbCip.md\", \"docs/drivers/AbLegacy.md\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests\", \"modelTier\": \"mechanical\", \"model\": \"haiku\", \"acceptanceCriteriaCount\": 5}\n```",
"blockedBy": [
21,
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli;
/// <summary>
/// The CLI's <c>--transport</c> choice. <see cref="Default"/> leaves
/// <see cref="AbCipDriverOptions.Transport"/> null so the driver resolves it through
/// <see cref="AbCipTransportResolver"/> exactly as an absent <c>transport</c> key in a deployed config does.
/// </summary>
public enum AbCipCliTransport
{
/// <summary>Use the driver's default (resolved by <see cref="AbCipTransportResolver"/>).</summary>
Default,
/// <summary>The managed native transport.</summary>
Native,
/// <summary>The libplctag fallback transport.</summary>
Libplctag,
}
/// <summary>Maps a <see cref="AbCipCliTransport"/> onto the driver option.</summary>
public static class AbCipCliTransportExtensions
{
/// <summary>Returns the configured transport: null for <see cref="AbCipCliTransport.Default"/>.</summary>
/// <param name="choice">The CLI choice.</param>
/// <returns>The value for <see cref="AbCipDriverOptions.Transport"/>.</returns>
public static AbCipTransport? ToConfigured(this AbCipCliTransport choice) => choice switch
{
AbCipCliTransport.Native => AbCipTransport.Native,
AbCipCliTransport.Libplctag => AbCipTransport.Libplctag,
_ => null,
};
}
@@ -28,8 +28,8 @@ public abstract class AbCipCommandBase : DriverCommandBase
/// <summary>Gets the transport stack used to reach the PLC.</summary>
[CommandOption("transport", Description =
"Transport stack: libplctag (default) or native (managed EtherNet/IP + CIP).")]
public AbCipTransport Transport { get; init; } = AbCipTransport.Libplctag;
"Transport stack: default (= native), native (managed EtherNet/IP + CIP) or libplctag (fallback).")]
public AbCipCliTransport Transport { get; init; } = AbCipCliTransport.Default;
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
@@ -73,7 +73,7 @@ public abstract class AbCipCommandBase : DriverCommandBase
WriteIdempotent: t.WriteIdempotent,
DeviceName: t.DeviceHostAddress))],
Timeout = Timeout,
Transport = Transport,
Transport = Transport.ToConfigured(),
Probe = new AbCipProbeOptions { Enabled = false },
EnableControllerBrowse = false,
EnableAlarmProjection = false,
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli;
/// <summary>
/// The CLI's <c>--transport</c> choice. <see cref="Default"/> leaves
/// <see cref="AbLegacyDriverOptions.Transport"/> null so the driver resolves it through
/// <see cref="AbLegacyTransportResolver"/> exactly as an absent <c>transport</c> key in a deployed config does.
/// </summary>
public enum AbLegacyCliTransport
{
/// <summary>Use the driver's default (resolved by <see cref="AbLegacyTransportResolver"/>).</summary>
Default,
/// <summary>The managed native transport.</summary>
Native,
/// <summary>The libplctag fallback transport.</summary>
Libplctag,
}
/// <summary>Maps a <see cref="AbLegacyCliTransport"/> onto the driver option.</summary>
public static class AbLegacyCliTransportExtensions
{
/// <summary>Returns the configured transport: null for <see cref="AbLegacyCliTransport.Default"/>.</summary>
/// <param name="choice">The CLI choice.</param>
/// <returns>The value for <see cref="AbLegacyDriverOptions.Transport"/>.</returns>
public static AbLegacyTransport? ToConfigured(this AbLegacyCliTransport choice) => choice switch
{
AbLegacyCliTransport.Native => AbLegacyTransport.Native,
AbLegacyCliTransport.Libplctag => AbLegacyTransport.Libplctag,
_ => null,
};
}
@@ -27,9 +27,10 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
/// <summary>Gets the transport stack used to reach the PLC.</summary>
[CommandOption("transport", Description =
"Transport stack: libplctag (default) or native (managed EtherNet/IP + PCCC; PLC-5 N-file reads only, " +
"no status-bit writes, LogixPccc unverified).")]
public AbLegacyTransport Transport { get; init; } = AbLegacyTransport.Libplctag;
"Transport stack: default, native or libplctag. default = libplctag for every --plc-type; native is " +
"opt-in (wire-proven only for N-file reads/writes and an F8 read; PLC-5 native is N-file reads only, " +
"LogixPccc unverified).")]
public AbLegacyCliTransport Transport { get; init; } = AbLegacyCliTransport.Default;
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
@@ -62,7 +63,7 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
RawTags = [.. tags.Select(t => new RawTagEntry(
t.Name, AbLegacyTagDefinitionFactory.ToTagConfig(t), t.WriteIdempotent, DeviceName: t.DeviceHostAddress))],
Timeout = Timeout,
Transport = Transport,
Transport = Transport.ToConfigured(),
Probe = new AbLegacyProbeOptions { Enabled = false },
};
@@ -1,3 +1,4 @@
using System.Diagnostics;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
@@ -23,9 +24,55 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
/// <summary>Forward Open frames sent by every connection in the process (fallback included). Gate diagnostics.</summary>
internal static int ForwardOpenCount;
/// <summary>Forward Close requests put on the wire by every connection in the process. Gate diagnostics.</summary>
internal static int ForwardClosesSent;
/// <summary>Forward Close requests that got a reply within their budget. Gate diagnostics.</summary>
internal static int ForwardClosesAcked;
/// <summary>Channel sends by every connection in the process (keep-alives included). Gate diagnostics.</summary>
internal static int RequestsSent;
// Round-trip statistics of completed exchanges, process-wide (gate diagnostics: T21 latency attribution).
// Two Stopwatch reads and four interlocked ops per request; reset by the harness between timed passes.
private static long _exchangeCount;
private static long _exchangeTotalTicks;
private static long _exchangeMinTicks = long.MaxValue;
private static long _exchangeMaxTicks;
/// <summary>Round-trip statistics of the exchanges completed since the last <see cref="ResetExchangeStats"/>.</summary>
internal readonly record struct ExchangeStats(long Count, double MinMs, double MaxMs, double MeanMs);
/// <summary>Clears the process-wide round-trip statistics. Gate diagnostics.</summary>
internal static void ResetExchangeStats()
{
Interlocked.Exchange(ref _exchangeCount, 0);
Interlocked.Exchange(ref _exchangeTotalTicks, 0);
Interlocked.Exchange(ref _exchangeMinTicks, long.MaxValue);
Interlocked.Exchange(ref _exchangeMaxTicks, 0);
}
/// <summary>Snapshot of the process-wide round-trip statistics. Gate diagnostics.</summary>
internal static ExchangeStats ReadExchangeStats()
{
var count = Interlocked.Read(ref _exchangeCount);
if (count == 0) return new ExchangeStats(0, 0, 0, 0);
static double Ms(long ticks) => ticks * 1000.0 / Stopwatch.Frequency;
return new ExchangeStats(count, Ms(Interlocked.Read(ref _exchangeMinTicks)), Ms(Interlocked.Read(ref _exchangeMaxTicks)),
Ms(Interlocked.Read(ref _exchangeTotalTicks)) / count);
}
private static void RecordExchange(long ticks)
{
Interlocked.Increment(ref _exchangeCount);
Interlocked.Add(ref _exchangeTotalTicks, ticks);
long seen;
while (ticks < (seen = Interlocked.Read(ref _exchangeMinTicks))
&& Interlocked.CompareExchange(ref _exchangeMinTicks, ticks, seen) != seen) { }
while (ticks > (seen = Interlocked.Read(ref _exchangeMaxTicks))
&& Interlocked.CompareExchange(ref _exchangeMaxTicks, ticks, seen) != seen) { }
}
/// <summary>Open attempts (TCP connect + RegisterSession + Forward Open, a fallback open included) started by
/// every connection in the process — counted before any I/O, so a refused attempt counts too. Gate diagnostics.</summary>
internal static int OpenAttempts;
@@ -246,14 +293,17 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
Interlocked.Exchange(ref _lastSendTimestamp, _time.GetTimestamp());
using var deadline = new CancellationTokenSource(ExchangeBudget, _time);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, deadline.Token);
var started = Stopwatch.GetTimestamp();
try
{
return _o.Routing switch
var reply = _o.Routing switch
{
CipRouting.Connected => await open.Client.SendUnitDataAsync(open.Conn!.OTConnectionId, cip, _o.Timeout, linked.Token).ConfigureAwait(false),
CipRouting.UnconnectedSend => await open.Client.SendRRDataAsync(ConnectionManagerServices.UnconnectedSend(cip.Span, _route), _o.Timeout, linked.Token).ConfigureAwait(false),
_ => await open.Client.SendRRDataAsync(cip, _o.Timeout, linked.Token).ConfigureAwait(false),
};
RecordExchange(Stopwatch.GetTimestamp() - started);
return reply;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested && deadline.IsCancellationRequested)
{
@@ -346,9 +396,11 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
{
var budget = TimeSpan.FromSeconds(1);
using var cts = new CancellationTokenSource(budget, _time);
Interlocked.Increment(ref ForwardClosesSent);
await open.Client.SendRRDataAsync(
ConnectionManagerServices.ForwardClose(open.ConnectionSerial, _o.VendorId, _o.OriginatorSerial, _route),
budget, cts.Token).ConfigureAwait(false);
Interlocked.Increment(ref ForwardClosesAcked);
}
catch (Exception ex) when (ex is AbTransportException or OperationCanceledException)
{
@@ -49,6 +49,9 @@ public sealed class DeviceSessionPool
Shared._logger = loggerFactory.CreateLogger<DeviceSessionPool>();
}
/// <summary>Wiring-test hook: the logger this pool currently writes to (final review I2).</summary>
internal ILogger Logger => _logger;
/// <summary>Test hook: keys with a live entry.</summary>
internal int EntryCount { get { lock (_lock) return _entries.Count; } }
@@ -1,8 +1,13 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
/// <summary>PCCC file-type codes for the protected typed logical read/write commands (0xA2/0xAA).
/// N (0x89) and F (0x8A) are verified against the golden captures; the rest follow the PCCC
/// data-table file-type table.</summary>
/// <para>Wire evidence (final review I3), all against the <c>ab_server</c> simulator, never a real PLC:
/// N (0x89) — byte parity with libplctag captures (SLC 500 read + write, MicroLogix read, PLC-5 read) and
/// native ab_server round trips (N7:0 read on all three profiles, N7:5 write on SLC 500); F (0x8A) — one
/// SLC 500 F8 read capture only (no native write on a wire).</para>
/// <para>S, B, T, C, R, ST, A, L, O and I are encoded from the PCCC data-table file-type table and are
/// UNVERIFIED natively — no capture and no simulator test. That includes S:0, the default probe address,
/// and the T/C/R sub-element (PRE/ACC/LEN/POS) word offsets.</para></summary>
public static class PcccFileType
{
/// <summary>Returns the file-type code for a file letter (case-insensitive), or null when unknown.</summary>
@@ -18,5 +18,6 @@
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests"/>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests"/>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests"/>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Host.Tests"/>
</ItemGroup>
</Project>
@@ -33,14 +33,21 @@ public sealed class AbCipDriverOptions
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>
/// Which transport stack the driver, its Test Connect probe and the CLI use. Defaults to
/// <see cref="AbCipTransport.Libplctag"/>.
/// Which transport stack the driver, its Test Connect probe and the CLI use. <c>null</c> (the
/// <c>transport</c> key absent) means "use the default": the node-level
/// <c>Drivers:AbCip:DefaultTransport</c> appsetting, which itself defaults to <see cref="AbCipTransport.Native"/>;
/// an explicit value always wins. The effective transport is resolved in one place,
/// <c>AbCipTransportResolver</c>. Set <see cref="AbCipTransport.Libplctag"/> to fall back.
/// <para>Changing this takes effect when the driver instance is RECREATED, not on
/// <c>ReinitializeAsync</c>: the factories are chosen once in the constructor and held across
/// Shutdown+Initialize. A deploy that changes the driver config stops and respawns the instance
/// (<c>DriverSpawnPlanner</c>, #516), so an operator edit does recreate it.</para>
/// </summary>
public AbCipTransport Transport { get; init; } = AbCipTransport.Libplctag;
/// <remarks>Null is never written: an absent key IS the "use the default" encoding, so a round-trip
/// through any serializer (the AdminUI form included) keeps a defaulted driver defaulted rather than
/// pinning it to whatever the default happened to be on the day it was saved.</remarks>
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public AbCipTransport? Transport { get; init; }
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
public AbCipProbeOptions Probe { get; init; } = new();
@@ -129,11 +136,9 @@ public sealed class AbCipDriverOptions
/// touching every other device on the same family.</param>
/// <param name="ConnectionSize">Per-device override for the Forward Open
/// ConnectionSize (Large Forward Open packet size in bytes). <c>null</c> inherits the family
/// profile's <c>DefaultConnectionSize</c>. Honoured by the driver layer; the underlying
/// libplctag 1.5.2 wrapper has no direct <c>ConnectionSize</c> property, so the value is
/// plumbed through <see cref="AbCipTagCreateParams"/> for forward-compat with future wrapper
/// versions or a custom tag-attribute path; current builds use the family profile default at
/// the wire layer regardless.</param>
/// profile's <c>DefaultConnectionSize</c>. The native transport honours it on the wire (its Forward
/// Open size). The libplctag transport ignores it: the libplctag 1.5.2 wrapper has no
/// <c>ConnectionSize</c> property, so libplctag opens with its own size for the <c>plc</c> attribute.</param>
public sealed record AbCipDeviceOptions(
string HostAddress,
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
@@ -6,9 +6,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// </summary>
public enum AbCipTransport
{
/// <summary>The libplctag-backed transport. The default.</summary>
/// <summary>The libplctag-backed transport. The fallback; selected explicitly or via the node-level <c>Drivers:AbCip:DefaultTransport</c>.</summary>
Libplctag,
/// <summary>The native managed EtherNet/IP + CIP transport (shared Driver.Ab.Transport stack).</summary>
/// <summary>The native managed EtherNet/IP + CIP transport (shared Driver.Ab.Transport stack). The default.</summary>
Native,
}
@@ -137,23 +137,28 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <summary>Initializes a new instance of the <see cref="AbCipDriver"/> class.</summary>
/// <param name="options">The driver configuration options.</param>
/// <param name="driverInstanceId">A unique identifier for this driver instance.</param>
/// <param name="tagFactory">Optional factory for creating tag runtimes; uses libplctag default if null.</param>
/// <param name="enumeratorFactory">Optional factory for enumerating tags; uses libplctag default if null.</param>
/// <param name="templateReaderFactory">Optional factory for reading UDT templates; uses libplctag default if null.</param>
/// <param name="tagFactory">Optional factory for creating tag runtimes; when null the resolved transport's (see <see cref="AbCipTransportResolver"/>) is used.</param>
/// <param name="enumeratorFactory">Optional factory for enumerating tags; when null the resolved transport's is used.</param>
/// <param name="templateReaderFactory">Optional factory for reading UDT templates; when null the resolved transport's is used.</param>
/// <param name="logger">Optional logger; uses null logger if not provided.</param>
/// <param name="defaultTransport">The node-level default for an absent <c>transport</c> key
/// (<c>Drivers:AbCip:DefaultTransport</c>); null means the compiled <see cref="AbCipTransportResolver.Default"/>.</param>
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
IAbCipTagFactory? tagFactory = null,
IAbCipTagEnumeratorFactory? enumeratorFactory = null,
IAbCipTemplateReaderFactory? templateReaderFactory = null,
ILogger<AbCipDriver>? logger = null)
ILogger<AbCipDriver>? logger = null,
AbCipTransport? defaultTransport = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
// Chosen once here and held across ReinitializeAsync (Shutdown+Initialize): a changed
// Transport in a reinit config is inert; a deploy recreates the instance instead.
_transport = options.Transport;
var defaults = AbCipTransportFactories.For(options.Transport);
var nodeDefault = defaultTransport ?? AbCipTransportResolver.Default;
_transport = AbCipTransportResolver.Resolve(options, nodeDefault);
_transportDescription = AbCipTransportResolver.Describe(options.Transport, nodeDefault);
var defaults = AbCipTransportFactories.For(_transport);
_tagFactory = tagFactory ?? defaults.Tags;
_enumeratorFactory = enumeratorFactory ?? defaults.Enumerators;
_templateReaderFactory = templateReaderFactory ?? defaults.Templates;
@@ -179,10 +184,29 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <summary>The tag factory chosen at construction; exposed so tests can pin transport selection.</summary>
internal IAbCipTagFactory TagFactory => _tagFactory;
/// <summary>The effective transport resolved at construction (see <see cref="AbCipTransportResolver"/>).
/// Describes the OPTION only: injected factories (tests) are used regardless.</summary>
public AbCipTransport EffectiveTransport => _transport;
// The transport chosen at construction (see the ctor); logged at every init so an operator can tell
// from the node log which stack serves this instance.
private readonly AbCipTransport _transport;
// Configured-vs-effective rendering of _transport for the init log, e.g. "Native (default)".
private readonly string _transportDescription;
// Tags whose type-mismatch Warning has already been logged (final review I4): a mis-authored tag is
// polled forever, so the Warning is once per tag per driver instance, not once per poll.
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _typeMismatchWarned =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>Logs a type-mismatch Warning the first time <paramref name="tagName"/> reports one on this instance.</summary>
private void WarnTypeMismatchOnce(string tagName, string message, params object?[] args)
{
if (_typeMismatchWarned.TryAdd(tagName, 0))
_logger.LogWarning(message, args);
}
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
@@ -289,7 +313,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_health = new DriverHealth(DriverState.Initializing, null, null);
_logger.LogInformation(
"AbCip driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}",
_driverInstanceId, _transport, _tagFactory.GetType().Name);
_driverInstanceId, _transportDescription, _tagFactory.GetType().Name);
try
{
if (!string.IsNullOrWhiteSpace(driverConfigJson))
@@ -795,6 +819,19 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
await runtime.ReadAsync(ct).ConfigureAwait(false);
var status = runtime.GetStatus();
if (status == (int)AbStatus.TypeMismatch)
{
// Final review I4: the controller ANSWERED with a type that disagrees with the authored
// one — a deterministic config error, not a comms fault. Keep the handle (evicting would
// re-create it — and, for a sole lease holder, re-Forward-Open — on every poll), leave
// health alone, warn once per tag.
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
WarnTypeMismatchOnce(def.Name,
"AbCip read of {Tag} on device {Device}: the controller's type does not match the authored " +
"{DataType}; reporting BadTypeMismatch until the tag's dataType is corrected (logged once per tag)",
reference, def.DeviceHostAddress, def.DataType);
return ReadOutcome.None;
}
if (status != 0)
{
// Evict the stale handle so the next call re-creates it.
@@ -822,11 +859,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
&& ExpectedScalarWidth(def.DataType) is { } width
&& runtime.GetSize() is var size && size >= 0 && size != width)
{
// Same class as the native TypeMismatch above (final review I4): deterministic, device
// answered — no Degraded, warn once per tag.
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
_logger.LogWarning(
"AbCip read of {Tag} on device {Device} returned {Size} bytes but its authored type {DataType} is {Width} bytes wide; check the tag's dataType",
WarnTypeMismatchOnce(def.Name,
"AbCip read of {Tag} on device {Device} returned {Size} bytes but its authored type {DataType} is {Width} bytes wide; check the tag's dataType (logged once per tag)",
reference, def.DeviceHostAddress, size, def.DataType, width);
return ReadOutcome.Degraded($"type mismatch reading {reference}: {size} bytes for {def.DataType}");
return ReadOutcome.None;
}
// Review I-1 — an array tag (the EXPLICIT IsArray flag) decodes the whole buffer into an
// element-typed CLR array (int[]/float[]/bool[]/string[]…), INCLUDING a 1-element array
@@ -846,6 +885,21 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
throw;
}
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
{
// A deterministic local refusal — not a comms fault: BadNotSupported, keep the handle, no
// health signal (Task 22 review r1).
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
return ReadOutcome.None;
}
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
{
// Final review I4: deterministic, keep the handle, no health signal, warn once per tag.
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
WarnTypeMismatchOnce(def.Name, "AbCip read of {Tag} on device {Device} refused: {Reason} (logged once per tag)",
reference, def.DeviceHostAddress, ate.Message);
return ReadOutcome.None;
}
catch (Exception ex)
{
// Transport exception — evict so the next read creates a fresh handle. An AbTransportException
@@ -892,6 +946,16 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
await runtime.ReadAsync(ct).ConfigureAwait(false);
var status = runtime.GetStatus();
if (status == (int)AbStatus.TypeMismatch)
{
// Final review I4: deterministic — keep the handle, no health signal, warn once.
StampGroupStatus(group, results, now, AbCipStatusMapper.BadTypeMismatch);
WarnTypeMismatchOnce(parent.Name,
"AbCip whole-UDT read of {Parent} on device {Device}: the controller's type does not match " +
"the authored type; {MemberCount} members report BadTypeMismatch (logged once per tag)",
group.ParentName, parent.DeviceHostAddress, group.Members.Count);
return ReadOutcome.None;
}
if (status != 0)
{
EvictRuntime(device, parent.Name);
@@ -920,6 +984,19 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
throw;
}
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
{
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNotSupported);
return ReadOutcome.None;
}
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
{
StampGroupStatus(group, results, now, AbCipStatusMapper.BadTypeMismatch);
WarnTypeMismatchOnce(parent.Name,
"AbCip whole-UDT read of {Parent} on device {Device} refused: {Reason} (logged once per tag)",
group.ParentName, parent.DeviceHostAddress, ate.Message);
return ReadOutcome.None;
}
catch (Exception ex)
{
EvictRuntime(device, parent.Name);
@@ -1003,7 +1080,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
opLock.Release();
}
if (status != 0)
if (status == (int)AbStatus.TypeMismatch)
{
// Final review I4: deterministic — keep the handle, leave health, warn once per tag.
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
WarnTypeMismatchOnce(def.Name,
"AbCip write to {Tag} on device {Device} refused: the controller's type does not match the authored {DataType} (logged once per tag)",
w.FullReference, def.DeviceHostAddress, def.DataType);
}
else if (status != 0)
{
EvictRuntime(device, def.Name);
results[i] = new WriteResult(AbCipStatusMapper.MapStatus(status));
@@ -1056,6 +1141,25 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
"AbCip write value out of range for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
}
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
{
// A deterministic local refusal (e.g. the native runtime cannot type the write) — not a comms
// fault: BadNotSupported, keep the handle, leave health alone (Task 22 review r1).
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
_logger.LogWarning(ate,
"AbCip write refused (not supported) for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
}
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
{
// Final review I4: the controller's type disagrees with the authored one (native type gate, or
// CIP 0xFF/0x2107 from the controller) — deterministic: BadTypeMismatch, keep the handle, leave
// health alone, warn once per tag. Retrying cannot succeed until the config is corrected.
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
WarnTypeMismatchOnce(def.Name,
"AbCip write to {Tag} on device {Device} refused: {Reason} (logged once per tag)",
w.FullReference, def.DeviceHostAddress, ate.Message);
}
catch (Exception ex)
{
// Transport / wire error — evict so the next write creates a fresh handle.
@@ -24,10 +24,14 @@ public static class AbCipDriverFactoryExtensions
/// </summary>
/// <param name="registry">The driver factory registry to register with.</param>
/// <param name="loggerFactory">Optional logger factory for driver instances.</param>
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
/// <param name="defaultTransport">The node-level transport for drivers whose config has no
/// <c>transport</c> key (<c>Drivers:AbCip:DefaultTransport</c>, final review I1). An explicit
/// per-driver key still wins.</param>
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null,
AbCipTransport defaultTransport = AbCipTransportResolver.Default)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, defaultTransport));
}
/// <summary>
@@ -43,18 +47,21 @@ public static class AbCipDriverFactoryExtensions
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
/// <param name="loggerFactory">Optional logger factory for the driver instance.</param>
/// <param name="defaultTransport">The node-level default for an absent <c>transport</c> key.</param>
/// <returns>A configured <see cref="AbCipDriver"/> instance.</returns>
internal static AbCipDriver CreateInstance(
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory,
AbCipTransport defaultTransport = AbCipTransportResolver.Default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
var options = ParseOptions(driverInstanceId, driverConfigJson);
return new AbCipDriver(options, driverInstanceId, logger: loggerFactory?.CreateLogger<AbCipDriver>());
return new AbCipDriver(options, driverInstanceId, logger: loggerFactory?.CreateLogger<AbCipDriver>(),
defaultTransport: defaultTransport);
}
/// <summary>
/// Deserialise an AB CIP driver-config JSON document into <see cref="AbCipDriverOptions"/>.
/// Shared by <see cref="CreateInstance(string, string, ILoggerFactory?)"/> (first construction) and
/// Shared by <see cref="CreateInstance(string, string, ILoggerFactory?, AbCipTransport)"/> (first construction) and
/// <see cref="AbCipDriver.InitializeAsync"/> / <see cref="AbCipDriver.ReinitializeAsync"/>
/// so a reinitialize with a changed config JSON (new device, new tag, changed timeout)
/// actually takes effect rather than being silently discarded.
@@ -96,8 +103,11 @@ public static class AbCipDriverFactoryExtensions
ProbeTagPath = dto.Probe?.ProbeTagPath,
},
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
Transport = ParseEnum<AbCipTransport>(dto.Transport, "driver", driverInstanceId, "Transport",
fallback: AbCipTransport.Libplctag),
// Absent ⇒ null ("use the default", resolved by AbCipTransportResolver); a bad value still
// fails exactly as any other bad enum field does.
Transport = string.IsNullOrWhiteSpace(dto.Transport)
? null
: ParseEnum<AbCipTransport>(dto.Transport, tagName: null, driverInstanceId, "Transport"),
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
EnableAlarmProjection = dto.EnableAlarmProjection ?? false,
AlarmPollInterval = TimeSpan.FromMilliseconds(dto.AlarmPollIntervalMs ?? 1_000),
@@ -120,19 +130,25 @@ public static class AbCipDriverFactoryExtensions
private static TimeSpan PositiveTimeoutOrDefault(int? ms, int defaultMs) =>
TimeSpan.FromMilliseconds(ms is int v and > 0 ? v : defaultMs);
// tagName null = a driver-level field: the error text names the driver config, not a tag.
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field,
T? fallback = null) where T : struct, Enum
{
var subject = tagName is null ? $"config in '{driverInstanceId}'" : $"tag '{tagName}'";
if (string.IsNullOrWhiteSpace(raw))
{
if (fallback.HasValue) return fallback.Value;
throw new InvalidOperationException(
$"AB CIP tag '{tagName ?? "<unnamed>"}' in '{driverInstanceId}' missing {field}");
tagName is null
? $"AB CIP {subject} missing {field}"
: $"AB CIP {subject} in '{driverInstanceId}' missing {field}");
}
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
// Enum.TryParse also accepts any integer string ("7"), defined or not; an undefined value would
// otherwise fall through to a switch's default arm silently (Task 22 review r1 M2).
return Enum.TryParse<T>(raw, ignoreCase: true, out var v) && Enum.IsDefined(v)
? v
: throw new InvalidOperationException(
$"AB CIP tag '{tagName}' has unknown {field} '{raw}'. " +
$"AB CIP {subject} has unknown {field} '{raw}'. " +
$"Expected one of {string.Join(", ", Enum.GetNames<T>())}");
}
@@ -146,7 +162,7 @@ public static class AbCipDriverFactoryExtensions
internal sealed class AbCipDriverConfigDto
{
/// <summary>
/// Gets or sets the transport (<c>Libplctag</c> or <c>Native</c>, case-insensitive); absent means Libplctag.
/// Gets or sets the transport (<c>Libplctag</c> or <c>Native</c>, case-insensitive); absent means the node default.
/// </summary>
public string? Transport { get; init; }
@@ -34,11 +34,22 @@ public sealed class AbCipDriverProbe : IDriverProbe
};
private readonly IAbCipTagFactory? _tagFactory;
private readonly AbCipTransport _defaultTransport;
/// <summary>Initializes a new instance of the <see cref="AbCipDriverProbe"/> class.</summary>
/// <param name="tagFactory">Factory for the probe's tag runtime; when null the config's
/// <c>Transport</c> selects the native or libplctag-backed one.</param>
public AbCipDriverProbe(IAbCipTagFactory? tagFactory = null) => _tagFactory = tagFactory;
/// <param name="defaultTransport">The node-level default for a config with no <c>transport</c> key — the
/// SAME value the node's driver factory uses, so Test Connect probes the stack the driver would run.</param>
public AbCipDriverProbe(IAbCipTagFactory? tagFactory = null,
AbCipTransport defaultTransport = AbCipTransportResolver.Default)
{
_tagFactory = tagFactory;
_defaultTransport = defaultTransport;
}
/// <summary>The node-level default this probe resolves an absent key to (wiring tests).</summary>
public AbCipTransport DefaultTransport => _defaultTransport;
/// <inheritdoc />
public string DriverType => "AbCip";
@@ -123,7 +134,7 @@ public sealed class AbCipDriverProbe : IDriverProbe
ConnectionSize: firstDevice?.ConnectionSize ?? profile.DefaultConnectionSize,
UseConnectedMessaging: profile.SupportsConnectedMessaging);
var rt = (_tagFactory ?? AbCipTransportFactories.For(opts.Transport).Tags).Create(p);
var rt = (_tagFactory ?? AbCipTransportFactories.For(AbCipTransportResolver.Resolve(opts, _defaultTransport)).Tags).Create(p);
try
{
await rt.InitializeAsync(cts.Token);
@@ -0,0 +1,65 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// The ONE place an AbCip driver's effective transport is decided from its configured
/// <see cref="AbCipDriverOptions.Transport"/>. The driver constructor, the Test Connect probe and the
/// CLI (through the driver) all resolve through here, so they cannot disagree about what an absent
/// <c>transport</c> key means.
/// <para>Precedence (final review I1): an explicit per-driver <c>transport</c> key wins; otherwise the
/// node-level default (<c>Drivers:AbCip:DefaultTransport</c>, read in the Host composition root and passed
/// in as <c>nodeDefault</c>); otherwise the compiled <see cref="Default"/>.</para>
/// </summary>
public static class AbCipTransportResolver
{
/// <summary>The compiled default: the transport an absent key selects when no node default is configured.</summary>
public const AbCipTransport Default = AbCipTransport.Native;
/// <summary>Returns the effective transport: an explicit value always wins; null means <paramref name="nodeDefault"/>.</summary>
/// <param name="configured">The configured transport, or null for "use the default".</param>
/// <param name="nodeDefault">The node-level default for drivers with no key (the compiled <see cref="Default"/> unless pinned).</param>
/// <returns>The transport the driver runs on.</returns>
public static AbCipTransport Resolve(AbCipTransport? configured, AbCipTransport nodeDefault = Default) =>
configured ?? nodeDefault;
/// <summary>Returns the effective transport for <paramref name="options"/>.</summary>
/// <param name="options">The driver options.</param>
/// <param name="nodeDefault">The node-level default for drivers with no key.</param>
/// <returns>The transport the driver runs on.</returns>
public static AbCipTransport Resolve(AbCipDriverOptions options, AbCipTransport nodeDefault = Default)
{
ArgumentNullException.ThrowIfNull(options);
return Resolve(options.Transport, nodeDefault);
}
/// <summary>
/// Parses a transport name the way the driver-config <c>transport</c> key and the node-level default
/// are parsed: a defined enum NAME, case-insensitive. Numeric strings ("0", "7") and the flags form
/// ("Native,Libplctag") are rejected — <c>Enum.TryParse</c> alone accepts both.
/// </summary>
/// <param name="raw">The raw value.</param>
/// <param name="transport">The parsed transport.</param>
/// <returns>True when <paramref name="raw"/> names a transport.</returns>
public static bool TryParse(string? raw, out AbCipTransport transport)
{
transport = Default;
if (string.IsNullOrWhiteSpace(raw)) return false;
// Exact name match only: Enum.TryParse would also accept "1" and the flags form "Native,Libplctag".
var name = Enum.GetNames<AbCipTransport>()
.FirstOrDefault(n => string.Equals(n, raw.Trim(), StringComparison.OrdinalIgnoreCase));
return name is not null && Enum.TryParse(name, out transport);
}
/// <summary>
/// Operator-facing description of the configured-vs-effective transport for the init log line:
/// <c>Native (default)</c> when the key was absent and the compiled default applies,
/// <c>Libplctag (node default)</c> when the key was absent and the node pinned a different default,
/// <c>Native (configured)</c> when the key was explicit.
/// </summary>
/// <param name="configured">The configured transport, or null.</param>
/// <param name="nodeDefault">The node-level default.</param>
/// <returns>A short description.</returns>
public static string Describe(AbCipTransport? configured, AbCipTransport nodeDefault = Default) =>
configured is not null ? $"{configured} (configured)"
: nodeDefault == Default ? $"{nodeDefault} (default)"
: $"{nodeDefault} (node default)";
}
@@ -0,0 +1,143 @@
using System.Collections.Concurrent;
using System.Reflection;
using libplctag;
using libplctag.NativeImport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Releases the native tag a FAILED libplctag initialisation leaves behind (T21 press pass 1 crash).
/// </summary>
/// <remarks>
/// <para>libplctag.NET 1.5.2's <c>NativeTagWrapper.InitializeAsync</c> calls <c>plc_tag_create_ex</c>, which returns a
/// native handle with the wrapper's <c>callback_func_ex</c> delegate registered, and only then waits for the
/// <c>Created</c> event. When that event carries an error (a tag the controller does not have, a refused path, ...)
/// it throws WITHOUT setting <c>_isInitialized</c>, and <c>Dispose()</c> unregisters and destroys only
/// <c>if (_isInitialized)</c>. So the native tag outlives its wrapper with the callback still registered; once the
/// wrapper is collected the delegate's thunk is freed, and the next event libplctag raises on that tag — at the
/// latest the <c>DESTROYED</c> event from <c>plc_tag_shutdown</c>, which the native library runs from
/// <c>atexit</c> — kills the process with "A callback was made on a garbage collected delegate of type
/// 'libplctag.NativeImport!libplctag.NativeImport.plctag+callback_func_ex::Invoke'". A timed-out initialisation
/// unregisters the callback but never destroys the handle either, so that native tag keeps reconnecting in the
/// background for the life of the process.</para>
/// <para>The wrapper exposes no handle, so it is read by reflection from the two private fields of the pinned
/// 1.5.2 wrapper (<c>Tag._tag</c>, <c>NativeTagWrapper.nativeTagHandle</c>). If a future wrapper renames them the
/// release cannot run; the tag is then rooted for the life of the process instead, which keeps its delegate
/// alive. That is no crash but an UNBOUNDED leak — one native tag (holding its libplctag session open) per failed
/// create, and the data path re-creates a NotFound tag on every read — so <c>LibplctagOrphanReleaseTests</c> fails, so the bump is noticed.</para>
/// </remarks>
internal static class LibplctagOrphanRelease
{
private const BindingFlags Private = BindingFlags.NonPublic | BindingFlags.Instance;
private static readonly FieldInfo? WrapperField = typeof(Tag).GetField("_tag", Private);
// Final review M2: the field must also still be an int. A wrapper that retyped it (IntPtr, long) would make
// the cast in HandleOf throw; treating it as unreadable instead degrades to the rooting fallback ("keep
// alive"), which the pin test reports.
private static readonly FieldInfo? HandleField =
WrapperField?.FieldType.GetField("nativeTagHandle", Private) is { } f && f.FieldType == typeof(int) ? f : null;
/// <summary>Tags whose handle could not be read: kept reachable so their callback delegate is never collected.</summary>
private static readonly ConcurrentBag<Tag> Rooted = [];
private static int _released;
/// <summary>True when the pinned wrapper's private fields were found, so a failed tag can be destroyed.</summary>
internal static bool CanReadHandles => HandleField is not null && !ForceRootingForTest;
/// <summary>Test seam (review r1 M1): take the rooting fallback as if the wrapper's fields had been renamed.</summary>
internal static bool ForceRootingForTest { get; set; }
/// <summary>Orphaned native tags destroyed so far (process-wide; diagnostics and tests).</summary>
internal static int Released => Volatile.Read(ref _released);
/// <summary>Orphaned tags rooted for the process lifetime because their handle could not be read.</summary>
internal static int RootedCount => Rooted.Count;
/// <summary>The native handle the wrapper holds (0 when none was ever created, or unreadable). Never throws:
/// callers run it inside a catch block, where an exception would replace the failure being handled.</summary>
internal static int HandleOf(Tag tag)
{
try
{
return HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper
&& HandleField.GetValue(wrapper) is int handle ? handle : 0;
}
catch (Exception)
{
return 0;
}
}
/// <summary>Clears the wrapper's handle after a destroy, so a repeat release is a no-op (review r1 M2).</summary>
private static void ClearHandle(Tag tag)
{
if (HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper) HandleField.SetValue(wrapper, 0);
}
/// <summary>
/// Runs a libplctag operation that may initialise <paramref name="tag"/> (InitializeAsync, or a first
/// Read/Write, which initialise on demand). If it fails and leaves the tag uninitialised, the native tag it
/// created is unregistered and destroyed before the failure propagates.
/// </summary>
internal static async Task RunAsync(Tag tag, Func<Task> op)
{
try
{
await op().ConfigureAwait(false);
}
catch
{
ReleaseIfOrphaned(tag);
throw;
}
}
/// <inheritdoc cref="RunAsync(Tag, Func{Task})"/>
internal static async Task<T> RunAsync<T>(Tag tag, Func<Task<T>> op)
{
try
{
return await op().ConfigureAwait(false);
}
catch
{
ReleaseIfOrphaned(tag);
throw;
}
}
/// <summary>
/// Destroys the native tag behind an UNINITIALISED wrapper, unregistering its callback first. No-op for an
/// initialised tag (its own <c>Dispose</c> handles it) or one that never got a handle. Never throws.
/// </summary>
/// <returns>The status the native tag held just before it was destroyed (what <c>GetStatus()</c> would have
/// reported), or null when nothing was destroyed.</returns>
internal static Status? ReleaseIfOrphaned(Tag tag)
{
try
{
if (tag.IsInitialized) return null;
if (!CanReadHandles)
{
Rooted.Add(tag);
return null;
}
var handle = HandleOf(tag);
if (handle <= 0) return null; // plc_tag_create_ex refused synchronously: nothing native exists
var status = (Status)plctag.plc_tag_status(handle);
plctag.plc_tag_unregister_callback(handle);
plctag.plc_tag_destroy(handle);
ClearHandle(tag);
Interlocked.Increment(ref _released);
return status;
}
catch (Exception)
{
// A release must never mask the initialisation failure it follows. Root the tag so that, whatever
// state the native side is in, its callback delegate stays alive.
Rooted.Add(tag);
return null;
}
}
}
@@ -12,12 +12,13 @@ internal static class LibplctagListingReader
AbCipTagCreateParams deviceParams, string listingName, CancellationToken cancellationToken)
{
using var tag = BuildSymbolListTag(deviceParams, listingName);
return await LibplctagStatusTranslation.Wrap(async () =>
// A failed initialisation would otherwise leave the native tag (and its callback) behind: see LibplctagOrphanRelease.
return await LibplctagOrphanRelease.RunAsync(tag, () => LibplctagStatusTranslation.Wrap(async () =>
{
await tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await tag.ReadAsync(cancellationToken).ConfigureAwait(false);
return tag.GetBuffer();
}).ConfigureAwait(false);
})).ConfigureAwait(false);
}
/// <summary>
@@ -14,6 +14,15 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
{
private readonly Tag _tag;
/// <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.
/// </summary>
private Status? _releasedStatus;
/// <summary>The native handle a failed initialisation destroyed (0 if none); the wrapper's own field is cleared.</summary>
private int _releasedHandle;
/// <summary>Initializes a new instance of the LibplctagTagRuntime class.</summary>
/// <param name="p">The tag creation parameters.</param>
public LibplctagTagRuntime(AbCipTagCreateParams p)
@@ -42,18 +51,52 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
/// <inheritdoc />
public Task InitializeAsync(CancellationToken cancellationToken) =>
LibplctagStatusTranslation.Wrap(() => _tag.InitializeAsync(cancellationToken));
RunReleasingOrphanAsync(() => _tag.InitializeAsync(cancellationToken));
/// <inheritdoc />
/// <remarks>libplctag initialises an uninitialised tag on its first read, so this path can orphan one too.</remarks>
public Task ReadAsync(CancellationToken cancellationToken) =>
LibplctagStatusTranslation.Wrap(() => _tag.ReadAsync(cancellationToken));
RunReleasingOrphanAsync(() => _tag.ReadAsync(cancellationToken));
/// <inheritdoc />
public Task WriteAsync(CancellationToken cancellationToken) =>
LibplctagStatusTranslation.Wrap(() => _tag.WriteAsync(cancellationToken));
RunReleasingOrphanAsync(() => _tag.WriteAsync(cancellationToken));
/// <summary>
/// Runs a libplctag operation; when it fails and leaves the tag uninitialised, destroys the native tag it
/// created before the failure propagates, so its callback can never fire into a collected delegate (T21 press
/// pass 1 crash). The status that tag held is kept for <see cref="GetStatus"/>.
/// </summary>
private async Task RunReleasingOrphanAsync(Func<Task> op)
{
try
{
await LibplctagStatusTranslation.Wrap(op).ConfigureAwait(false);
_releasedStatus = null;
_releasedHandle = 0;
}
catch
{
var handle = LibplctagOrphanRelease.HandleOf(_tag);
if (LibplctagOrphanRelease.ReleaseIfOrphaned(_tag) is { } status)
{
_releasedStatus = status;
_releasedHandle = handle;
}
throw;
}
}
/// <summary>The native libplctag handle this runtime's tag holds, or the one a failed initialisation destroyed
/// (0 if none was created). Tests only.</summary>
internal int NativeHandle => _releasedHandle != 0 ? _releasedHandle : LibplctagOrphanRelease.HandleOf(_tag);
/// <summary>The handle the libplctag wrapper itself still holds (0 once a release cleared it). Tests only.</summary>
internal int WrapperHandle => LibplctagOrphanRelease.HandleOf(_tag);
/// <inheritdoc />
public int GetStatus() => (int)LibplctagStatusTranslation.ToAbStatus(_tag.GetStatus());
public int GetStatus() =>
(int)LibplctagStatusTranslation.ToAbStatus(_releasedStatus ?? _tag.GetStatus());
/// <inheritdoc />
public int GetSize() => LibplctagStatusTranslation.WrapSync(() => _tag.GetSize());
@@ -21,12 +21,13 @@ internal sealed class LibplctagTemplateReader : IAbCipTemplateReader
_tag?.Dispose();
_tag = BuildTemplateTag(deviceParams, templateInstanceId);
var tag = _tag;
return await LibplctagStatusTranslation.Wrap(async () =>
// A failed initialisation would otherwise leave the native tag (and its callback) behind: see LibplctagOrphanRelease.
return await LibplctagOrphanRelease.RunAsync(tag, () => LibplctagStatusTranslation.Wrap(async () =>
{
await tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await tag.ReadAsync(cancellationToken).ConfigureAwait(false);
return tag.GetBuffer();
}).ConfigureAwait(false);
})).ConfigureAwait(false);
}
/// <summary>Builds the (uninitialized) libplctag tag for the <c>@udt/{id}</c> pseudo.</summary>
@@ -7,9 +7,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// connection from <see cref="DeviceSessionPool.Shared"/>, so all tags on one device share one CIP
/// connection and one request scheduler. Stateless; safe to share across devices.
/// </summary>
internal sealed class NativeAbCipTagFactory : IAbCipTagFactory
internal sealed class NativeAbCipTagFactory(DeviceSessionPool? pool = null) : IAbCipTagFactory
{
// Tests pass a private pool (and a fake target); production uses the process-wide one.
private readonly DeviceSessionPool _pool = pool ?? DeviceSessionPool.Shared;
/// <inheritdoc />
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) =>
new NativeAbCipTagRuntime(createParams, DeviceSessionPool.Shared);
new NativeAbCipTagRuntime(createParams, _pool);
}
@@ -41,7 +41,12 @@ public sealed record AbCipPlcFamilyProfile(
public static readonly AbCipPlcFamilyProfile CompactLogix = new(
LibplctagPlcAttribute: "compactlogix",
DefaultConnectionSize: 504, // 5069-L3x narrower buffer; safe baseline that never over-shoots
// Large Forward Open, matching libplctag: `plc=compactlogix` opens Large (0x5B, 4002) and the
// native transport must not pack into a third of the window (T21 latency harness: 504 cost ~3x
// the round trips per poll). A pre-v20 CompactLogix (1769-L3x) or a bridge that refuses the Large open
// is covered by CipConnection's fallback — a regular open at min(size, 504) inside the same
// throttled attempt. An explicit ConnectionSize (driver options or per device) still wins.
DefaultConnectionSize: 4002,
DefaultCipPath: "1,0",
SupportsRequestPacking: true,
SupportsConnectedMessaging: true,
@@ -26,14 +26,21 @@ public sealed class AbLegacyDriverOptions
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <summary>
/// Which transport stack the driver, its Test Connect probe and the CLI use. Defaults to
/// <see cref="AbLegacyTransport.Libplctag"/>.
/// Which transport stack the driver, its Test Connect probe and the CLI use. <c>null</c> (the
/// <c>transport</c> key absent) means "use the default": <see cref="AbLegacyTransport.Libplctag"/> for
/// every family. <see cref="AbLegacyTransport.Native"/> is opt-in (final review I3: on a wire it is proven
/// only for N-file reads/writes and one F8 read). An explicit value always wins. The
/// effective transport is resolved in one place, <c>AbLegacyTransportResolver</c>.
/// <para>Changing this takes effect when the driver instance is RECREATED, not on
/// <c>ReinitializeAsync</c>: the tag factory is chosen once in the constructor and held across
/// Shutdown+Initialize. A deploy that changes the driver config stops and respawns the instance
/// (<c>DriverSpawnPlanner</c>, #516), so an operator edit does recreate it.</para>
/// </summary>
public AbLegacyTransport Transport { get; init; } = AbLegacyTransport.Libplctag;
/// <remarks>Null is never written: an absent key IS the "use the default" encoding, so a round-trip
/// through any serializer (the AdminUI form included) keeps a defaulted driver defaulted rather than
/// pinning it to whatever the default happened to be on the day it was saved.</remarks>
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public AbLegacyTransport? Transport { get; init; }
/// <summary>Gets or sets the probe (connectivity check) options.</summary>
public AbLegacyProbeOptions Probe { get; init; } = new();
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
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;
@@ -70,8 +71,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
_driverInstanceId = driverInstanceId;
// Chosen once here and held across ReinitializeAsync (Shutdown+Initialize): a changed
// Transport in a reinit config is inert; a deploy recreates the instance instead.
_transport = options.Transport;
_tagFactory = tagFactory ?? (options.Transport == AbLegacyTransport.Native
_transport = AbLegacyTransportResolver.Resolve(options);
_transportDescription = AbLegacyTransportResolver.Describe(options.Transport);
_tagFactory = tagFactory ?? (_transport == AbLegacyTransport.Native
? new NativeAbLegacyTagFactory()
: new LibplctagLegacyTagFactory());
_logger = logger ?? NullLogger<AbLegacyDriver>.Instance;
@@ -93,10 +95,20 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
/// <summary>The tag factory chosen at construction; exposed so tests can pin transport selection.</summary>
internal IAbLegacyTagFactory TagFactory => _tagFactory;
/// <summary>The effective transport resolved at construction (see <see cref="AbLegacyTransportResolver"/>).
/// Describes the OPTION only: an injected <c>tagFactory</c> (tests) is used regardless.</summary>
internal AbLegacyTransport EffectiveTransport => _transport;
// The transport chosen at construction (see the ctor); logged at every init so an operator can tell
// from the node log which stack serves this instance.
private readonly AbLegacyTransport _transport;
// Configured-vs-effective rendering of _transport for the init log, e.g. "Libplctag (default)".
private readonly string _transportDescription;
// Set once the explicit-Native-on-a-limited-family Warning has been logged for this instance.
private bool _limitedFamilyWarned;
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
@@ -136,7 +148,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
_health = new DriverHealth(DriverState.Initializing, null, null);
_logger.LogInformation(
"AbLegacy driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}",
_driverInstanceId, _transport, _tagFactory.GetType().Name);
_driverInstanceId, _transportDescription, _tagFactory.GetType().Name);
try
{
// #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver
@@ -145,6 +157,21 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
if (HasConfigBody(driverConfigJson))
_options = AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
// Native is opt-in for every family (the default is libplctag); an operator may pick it explicitly
// even for PLC-5 / LogixPccc. Allowed, but the limitation is client-visible, so name it ONCE per instance —
// not on every init, which includes every comms-loss reconnect retry (Task 22 review r1 M4).
if (!_limitedFamilyWarned
&& _transport == AbLegacyTransport.Native
&& AbLegacyTransportResolver.NativeLimitedFamilies(_options.Devices) is { Count: > 0 } limited)
{
_limitedFamilyWarned = true;
_logger.LogWarning(
"AbLegacy driver {DriverInstanceId} runs the Native transport against {Families} device(s); native " +
"support is limited there (PLC-5: N-file reads only; LogixPccc: framing unverified). Set " +
"\"transport\": \"Libplctag\" to use the fallback stack.",
_driverInstanceId, string.Join(", ", limited));
}
foreach (var device in _options.Devices)
{
var addr = AbLegacyHostAddress.TryParse(device.HostAddress)
@@ -350,6 +377,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (OperationCanceledException) { throw; }
catch (AbTransportException ex) when (IsLocalRefusal(ex))
{
// The runtime refused the operation before the wire (e.g. a native PLC-5 non-N read, #571).
// Not a comms fault: BadNotSupported, keep the handle, leave health alone.
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
}
catch (Exception ex)
{
// Transport exception — evict so the next read creates a fresh handle (mirrors AbCip).
@@ -452,6 +485,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
{
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
}
catch (AbTransportException ex) when (IsLocalRefusal(ex))
{
// The runtime refused the write before the wire — a native status-bit sub-element write or a
// PLC-5 write (#571). Not a comms fault: BadNotSupported, keep the handle, leave health alone.
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
}
catch (Exception ex)
{
// Transport exception — evict so the next write/read creates a fresh handle (mirrors AbCip).
@@ -718,16 +757,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parentName,
Timeout: _options.Timeout));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch
{
runtime.Dispose();
throw;
}
device.ParentRuntimes[parentName] = runtime;
await InitializeOrKeepRefusedAsync(runtime, () => device.ParentRuntimes[parentName] = runtime, ct)
.ConfigureAwait(false);
return runtime;
}
finally
@@ -754,6 +785,39 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
private static int EffectiveArrayLength(AbLegacyTagDefinition def) =>
def.ArrayLength is int len && len >= 1 ? Math.Min(len, AbLegacyArray.MaxElements) : 1;
/// <summary>
/// True for a deterministic refusal of the operation itself (<see cref="AbStatus.Unsupported"/>) — the
/// native runtime raises it BEFORE anything reaches the wire (status-bit writes, PLC-5 non-N reads and
/// all PLC-5 writes), and libplctag's ErrorUnsupported maps to it too. It says nothing about the
/// device's reachability, so it must not evict the handle or degrade driver health.
/// </summary>
internal static bool IsLocalRefusal(Exception ex) =>
ex is AbTransportException { Status: AbStatus.Unsupported };
/// <summary>
/// Initializes a freshly created runtime. A NATIVE runtime whose init (one read) is refused locally is
/// still KEPT: the refusal is deterministic and per-operation, so caching it avoids re-creating — and
/// re-leasing the pooled device connection — on every poll. Any other failure disposes it, as before.
/// </summary>
private static async Task InitializeOrKeepRefusedAsync(IAbLegacyTagRuntime runtime, Action keep, CancellationToken ct)
{
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch (Exception ex) when (IsLocalRefusal(ex) && runtime is NativeAbLegacyTagRuntime)
{
keep();
throw;
}
catch
{
runtime.Dispose();
throw;
}
keep();
}
private async Task<IAbLegacyTagRuntime> EnsureTagRuntimeAsync(
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
{
@@ -780,16 +844,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
TagName: parsed.ToLibplctagName(),
Timeout: _options.Timeout,
ElementCount: EffectiveArrayLength(def)));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch
{
runtime.Dispose();
throw;
}
device.Runtimes[def.Name] = runtime;
await InitializeOrKeepRefusedAsync(runtime, () => device.Runtimes[def.Name] = runtime, ct)
.ConfigureAwait(false);
return runtime;
}
finally
@@ -97,8 +97,11 @@ public static class AbLegacyDriverFactoryExtensions
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
},
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
Transport = ParseEnum<AbLegacyTransport>(dto.Transport, driverInstanceId, "Transport",
fallback: AbLegacyTransport.Libplctag),
// Absent ⇒ null ("use the default" = Libplctag, resolved by AbLegacyTransportResolver);
// a bad value still fails exactly as any other bad enum field does.
Transport = string.IsNullOrWhiteSpace(dto.Transport)
? null
: ParseEnum<AbLegacyTransport>(dto.Transport, driverInstanceId, "Transport"),
};
return options;
@@ -127,7 +130,8 @@ public static class AbLegacyDriverFactoryExtensions
throw new InvalidOperationException(
$"AB Legacy {(tagName is null ? "config" : $"tag '{tagName}'")} in '{driverInstanceId}' missing {field}");
}
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
// Enum.TryParse also accepts any integer string ("7"), defined or not (Task 22 review r1 M2).
return Enum.TryParse<T>(raw, ignoreCase: true, out var v) && Enum.IsDefined(v)
? v
: throw new InvalidOperationException(
$"AB Legacy {(tagName is null ? "config" : $"tag '{tagName}'")} has unknown {field} '{raw}'. " +
@@ -149,7 +153,7 @@ public static class AbLegacyDriverFactoryExtensions
public int? TimeoutMs { get; init; }
/// <summary>
/// Gets or sets the transport (<c>Libplctag</c> or <c>Native</c>, case-insensitive); absent means Libplctag.
/// Gets or sets the transport (<c>Libplctag</c> or <c>Native</c>, case-insensitive); absent means Libplctag (the default).
/// </summary>
public string? Transport { get; init; }
@@ -106,7 +106,7 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
TagName: tagName,
Timeout: timeout);
var factory = _tagFactory ?? (opts.Transport == AbLegacyTransport.Native
var factory = _tagFactory ?? (AbLegacyTransportResolver.Resolve(opts) == AbLegacyTransport.Native
? new NativeAbLegacyTagFactory()
: new LibplctagLegacyTagFactory());
var rt = factory.Create(p);
@@ -0,0 +1,57 @@
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// The ONE place an AbLegacy driver's effective transport is decided from its configured
/// <see cref="AbLegacyDriverOptions.Transport"/>. The driver constructor, the Test Connect probe and the
/// CLI (through the driver) all resolve through here.
/// <para>An absent key means <see cref="Default"/> = <see cref="AbLegacyTransport.Libplctag"/> for EVERY
/// family (final review I3): on a wire the native PCCC path is proven only for N-file reads and writes
/// and one F8 read, so Native stays opt-in. An explicit value always wins (an operator choice). An
/// explicit Native on a PLC-5 / LogixPccc device is allowed but logs a once-per-instance Warning
/// (<see cref="NativeLimitedFamilies"/>).</para>
/// </summary>
public static class AbLegacyTransportResolver
{
/// <summary>Returns true for a family whose native transport support is limited.</summary>
/// <param name="family">The PLC family.</param>
/// <returns>True for PLC-5 and LogixPccc.</returns>
public static bool IsNativeLimited(AbLegacyPlcFamily family) =>
family is AbLegacyPlcFamily.Plc5 or AbLegacyPlcFamily.LogixPccc;
/// <summary>Returns the distinct native-limited families among <paramref name="devices"/>.</summary>
/// <param name="devices">The configured devices.</param>
/// <returns>The limited families present, in declaration order.</returns>
public static IReadOnlyList<AbLegacyPlcFamily> NativeLimitedFamilies(IEnumerable<AbLegacyDeviceOptions> devices)
{
ArgumentNullException.ThrowIfNull(devices);
return [.. devices.Select(d => d.PlcFamily).Where(IsNativeLimited).Distinct()];
}
/// <summary>The transport an absent / null <c>transport</c> key selects (all families).</summary>
public const AbLegacyTransport Default = AbLegacyTransport.Libplctag;
/// <summary>Returns the effective transport: an explicit value always wins; null means <see cref="Default"/>.</summary>
/// <param name="configured">The configured transport, or null for "use the default".</param>
/// <returns>The transport the driver runs on.</returns>
public static AbLegacyTransport Resolve(AbLegacyTransport? configured) => configured ?? Default;
/// <summary>Returns the effective transport for <paramref name="options"/>.</summary>
/// <param name="options">The driver options.</param>
/// <returns>The transport the driver runs on.</returns>
public static AbLegacyTransport Resolve(AbLegacyDriverOptions options)
{
ArgumentNullException.ThrowIfNull(options);
return Resolve(options.Transport);
}
/// <summary>
/// Operator-facing description of the configured-vs-effective transport for the init log line:
/// <c>Libplctag (default)</c> when the key was absent, <c>Native (configured)</c> when explicit.
/// </summary>
/// <param name="configured">The configured transport, or null.</param>
/// <returns>A short description.</returns>
public static string Describe(AbLegacyTransport? configured) =>
configured is null ? $"{Resolve(configured)} (default)" : $"{configured} (configured)";
}
@@ -13,6 +13,15 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
{
private readonly Tag _tag;
/// <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.
/// </summary>
private Status? _releasedStatus;
/// <summary>The native handle a failed initialisation destroyed (0 if none); the wrapper's own field is cleared.</summary>
private int _releasedHandle;
/// <summary>Initializes a new instance of the <see cref="LibplctagLegacyTagRuntime"/> class.</summary>
/// <param name="p">The parameters for tag creation.</param>
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
@@ -38,18 +47,52 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
/// <inheritdoc />
public Task InitializeAsync(CancellationToken cancellationToken) =>
LibplctagLegacyStatusTranslation.Wrap(() => _tag.InitializeAsync(cancellationToken));
RunReleasingOrphanAsync(() => _tag.InitializeAsync(cancellationToken));
/// <inheritdoc />
/// <remarks>libplctag initialises an uninitialised tag on its first read, so this path can orphan one too.</remarks>
public Task ReadAsync(CancellationToken cancellationToken) =>
LibplctagLegacyStatusTranslation.Wrap(() => _tag.ReadAsync(cancellationToken));
RunReleasingOrphanAsync(() => _tag.ReadAsync(cancellationToken));
/// <inheritdoc />
public Task WriteAsync(CancellationToken cancellationToken) =>
LibplctagLegacyStatusTranslation.Wrap(() => _tag.WriteAsync(cancellationToken));
RunReleasingOrphanAsync(() => _tag.WriteAsync(cancellationToken));
/// <summary>
/// Runs a libplctag operation; when it fails and leaves the tag uninitialised, destroys the native tag it
/// created before the failure propagates, so its callback can never fire into a collected delegate (T21 press
/// pass 1 crash). The status that tag held is kept for <see cref="GetStatus"/>.
/// </summary>
private async Task RunReleasingOrphanAsync(Func<Task> op)
{
try
{
await LibplctagLegacyStatusTranslation.Wrap(op).ConfigureAwait(false);
_releasedStatus = null;
_releasedHandle = 0;
}
catch
{
var handle = LibplctagOrphanRelease.HandleOf(_tag);
if (LibplctagOrphanRelease.ReleaseIfOrphaned(_tag) is { } status)
{
_releasedStatus = status;
_releasedHandle = handle;
}
throw;
}
}
/// <summary>The native libplctag handle this runtime's tag holds, or the one a failed initialisation destroyed
/// (0 if none was created). Tests only.</summary>
internal int NativeHandle => _releasedHandle != 0 ? _releasedHandle : LibplctagOrphanRelease.HandleOf(_tag);
/// <summary>The handle the libplctag wrapper itself still holds (0 once a release cleared it). Tests only.</summary>
internal int WrapperHandle => LibplctagOrphanRelease.HandleOf(_tag);
/// <inheritdoc />
public int GetStatus() => (int)LibplctagLegacyStatusTranslation.ToAbStatus(_tag.GetStatus());
public int GetStatus() =>
(int)LibplctagLegacyStatusTranslation.ToAbStatus(_releasedStatus ?? _tag.GetStatus());
/// <inheritdoc />
public object? DecodeValue(AbLegacyDataType type, int? bitIndex) => type switch
@@ -0,0 +1,143 @@
using System.Collections.Concurrent;
using System.Reflection;
using libplctag;
using libplctag.NativeImport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// Releases the native tag a FAILED libplctag initialisation leaves behind (T21 press pass 1 crash).
/// </summary>
/// <remarks>
/// <para>libplctag.NET 1.5.2's <c>NativeTagWrapper.InitializeAsync</c> calls <c>plc_tag_create_ex</c>, which returns a
/// native handle with the wrapper's <c>callback_func_ex</c> delegate registered, and only then waits for the
/// <c>Created</c> event. When that event carries an error (a tag the controller does not have, a refused path, ...)
/// it throws WITHOUT setting <c>_isInitialized</c>, and <c>Dispose()</c> unregisters and destroys only
/// <c>if (_isInitialized)</c>. So the native tag outlives its wrapper with the callback still registered; once the
/// wrapper is collected the delegate's thunk is freed, and the next event libplctag raises on that tag — at the
/// latest the <c>DESTROYED</c> event from <c>plc_tag_shutdown</c>, which the native library runs from
/// <c>atexit</c> — kills the process with "A callback was made on a garbage collected delegate of type
/// 'libplctag.NativeImport!libplctag.NativeImport.plctag+callback_func_ex::Invoke'". A timed-out initialisation
/// unregisters the callback but never destroys the handle either, so that native tag keeps reconnecting in the
/// background for the life of the process.</para>
/// <para>The wrapper exposes no handle, so it is read by reflection from the two private fields of the pinned
/// 1.5.2 wrapper (<c>Tag._tag</c>, <c>NativeTagWrapper.nativeTagHandle</c>). If a future wrapper renames them the
/// release cannot run; the tag is then rooted for the life of the process instead, which keeps its delegate
/// alive. That is no crash but an UNBOUNDED leak — one native tag (holding its libplctag session open) per failed
/// create, and the data path re-creates a NotFound tag on every read — so <c>LibplctagLegacyOrphanReleaseTests</c> fails, so the bump is noticed.</para>
/// </remarks>
internal static class LibplctagOrphanRelease
{
private const BindingFlags Private = BindingFlags.NonPublic | BindingFlags.Instance;
private static readonly FieldInfo? WrapperField = typeof(Tag).GetField("_tag", Private);
// Final review M2: the field must also still be an int. A wrapper that retyped it (IntPtr, long) would make
// the cast in HandleOf throw; treating it as unreadable instead degrades to the rooting fallback ("keep
// alive"), which the pin test reports.
private static readonly FieldInfo? HandleField =
WrapperField?.FieldType.GetField("nativeTagHandle", Private) is { } f && f.FieldType == typeof(int) ? f : null;
/// <summary>Tags whose handle could not be read: kept reachable so their callback delegate is never collected.</summary>
private static readonly ConcurrentBag<Tag> Rooted = [];
private static int _released;
/// <summary>True when the pinned wrapper's private fields were found, so a failed tag can be destroyed.</summary>
internal static bool CanReadHandles => HandleField is not null && !ForceRootingForTest;
/// <summary>Test seam (review r1 M1): take the rooting fallback as if the wrapper's fields had been renamed.</summary>
internal static bool ForceRootingForTest { get; set; }
/// <summary>Orphaned native tags destroyed so far (process-wide; diagnostics and tests).</summary>
internal static int Released => Volatile.Read(ref _released);
/// <summary>Orphaned tags rooted for the process lifetime because their handle could not be read.</summary>
internal static int RootedCount => Rooted.Count;
/// <summary>The native handle the wrapper holds (0 when none was ever created, or unreadable). Never throws:
/// callers run it inside a catch block, where an exception would replace the failure being handled.</summary>
internal static int HandleOf(Tag tag)
{
try
{
return HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper
&& HandleField.GetValue(wrapper) is int handle ? handle : 0;
}
catch (Exception)
{
return 0;
}
}
/// <summary>Clears the wrapper's handle after a destroy, so a repeat release is a no-op (review r1 M2).</summary>
private static void ClearHandle(Tag tag)
{
if (HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper) HandleField.SetValue(wrapper, 0);
}
/// <summary>
/// Runs a libplctag operation that may initialise <paramref name="tag"/> (InitializeAsync, or a first
/// Read/Write, which initialise on demand). If it fails and leaves the tag uninitialised, the native tag it
/// created is unregistered and destroyed before the failure propagates.
/// </summary>
internal static async Task RunAsync(Tag tag, Func<Task> op)
{
try
{
await op().ConfigureAwait(false);
}
catch
{
ReleaseIfOrphaned(tag);
throw;
}
}
/// <inheritdoc cref="RunAsync(Tag, Func{Task})"/>
internal static async Task<T> RunAsync<T>(Tag tag, Func<Task<T>> op)
{
try
{
return await op().ConfigureAwait(false);
}
catch
{
ReleaseIfOrphaned(tag);
throw;
}
}
/// <summary>
/// Destroys the native tag behind an UNINITIALISED wrapper, unregistering its callback first. No-op for an
/// initialised tag (its own <c>Dispose</c> handles it) or one that never got a handle. Never throws.
/// </summary>
/// <returns>The status the native tag held just before it was destroyed (what <c>GetStatus()</c> would have
/// reported), or null when nothing was destroyed.</returns>
internal static Status? ReleaseIfOrphaned(Tag tag)
{
try
{
if (tag.IsInitialized) return null;
if (!CanReadHandles)
{
Rooted.Add(tag);
return null;
}
var handle = HandleOf(tag);
if (handle <= 0) return null; // plc_tag_create_ex refused synchronously: nothing native exists
var status = (Status)plctag.plc_tag_status(handle);
plctag.plc_tag_unregister_callback(handle);
plctag.plc_tag_destroy(handle);
ClearHandle(tag);
Interlocked.Increment(ref _released);
return status;
}
catch (Exception)
{
// A release must never mask the initialisation failure it follows. Root the tag so that, whatever
// state the native side is in, its callback delegate stays alive.
Rooted.Add(tag);
return null;
}
}
}
@@ -99,7 +99,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
// is inherently scalar — arrayCount does not apply.
if (bitIndex is int bit && type == TwinCATDataType.Bool)
{
var parent = StripBitSuffix(symbolPath);
var parent = TwinCATBitAccess.ParentPath(symbolPath);
var parentResult = await _client.ReadValueAsync(parent, typeof(uint), cancellationToken)
.ConfigureAwait(false);
if (parentResult.ErrorCode != AdsErrorCode.NoError)
@@ -107,31 +107,29 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
return (ExtractBit(parentResult.Value, bit), TwinCATStatusMapper.Good);
}
// Array read — ADS reads array symbols natively into a typed managed array. We ask
// the SDK for the element-CLR-type's 1-D array (e.g. int[]); the .NET binding
// marshals the whole array in one read. The returned value is already the boxed
// element-typed array (int[] / float[] / bool[] / string[] / …), so the runtime's
// OPC UA layer sees a proper 1-D array value. ASSUMPTION (no live ADS here):
// AdsClient.ReadValueAsync(symbol, elementType.MakeArrayType(), ct) returns the
// typed array — this matches Beckhoff's documented generic ReadValue<T[]> surface
// (InfoSys tcadsnetref / SumRead samples). Verified only against the fake client.
if (arrayCount is int count && count > 0)
{
var elementType = MapToClrType(type);
var arrayType = elementType.MakeArrayType();
var arrResult = await _client.ReadValueAsync(symbolPath, arrayType, cancellationToken)
.ConfigureAwait(false);
if (arrResult.ErrorCode != AdsErrorCode.NoError)
return (null, MapAndSignal((uint)arrResult.ErrorCode));
return (arrResult.Value, TwinCATStatusMapper.Good);
}
// Scalar read: check the declared type against the PLC symbol first. The symbol is
// Resolve the PLC symbol first so the declared type can be checked against it. The symbol is
// resolved through the Beckhoff symbol cache (the same lookup ReadValueAsync makes
// internally), so this costs no extra round trip after the first read.
var (shape, symbol, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
var (shape, array, symbol, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
.ConfigureAwait(false);
if (symbolStatus != TwinCATStatusMapper.Good) return (null, symbolStatus);
// Array read: raw bytes at the layout TwinCATTypeGuard.CheckArray plans from the RESOLVED
// array type, decoded by TwinCATArrayCodec — the same decode a native array notification
// runs, so poll and notification publish identical arrays. The Beckhoff typed array read this
// replaced failed for most arrays on a live TC 2.11 target: the CLR element type had to match
// exactly (ARRAY OF TIME as uint[] threw), a multi-dimensional array needed a rank-N CLR
// array, and a member array was sliced by its under-reported symbol size.
if (arrayCount is int count)
{
if (TwinCATTypeGuard.CheckArray(type, count, shape, array, out var layout) is not null)
return (null, TwinCATStatusMapper.BadTypeMismatch);
var (arrayBytes, arrayStatus) = await ReadRawAsync(symbolPath, symbol!, layout.ByteLength, cancellationToken)
.ConfigureAwait(false);
return arrayStatus == TwinCATStatusMapper.Good
? (TwinCATArrayCodec.Decode(type, arrayBytes, layout, StringsAreUtf8()), TwinCATStatusMapper.Good)
: (null, arrayStatus);
}
if (TwinCATTypeGuard.CheckRead(type, shape) is not null)
return (null, TwinCATStatusMapper.BadTypeMismatch);
@@ -170,21 +168,34 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
/// <see cref="TwinCATTypeGuard"/> compares a declared type against. Uses the Beckhoff symbol
/// cache (lookup on), so repeat calls are local.
/// </summary>
private async Task<(TwinCATPlcSymbolShape shape, IAdsSymbol? symbol, uint status)> DescribeSymbolAsync(
string symbolPath, CancellationToken cancellationToken)
private async Task<(TwinCATPlcSymbolShape shape, TwinCATPlcArrayShape? array, IAdsSymbol? symbol, uint status)>
DescribeSymbolAsync(string symbolPath, CancellationToken cancellationToken)
{
var result = await _client.ReadSymbolAsync(symbolPath, cancellationToken).ConfigureAwait(false);
if (result.ErrorCode != AdsErrorCode.NoError)
return (default, null, MapAndSignal((uint)result.ErrorCode));
return (default, null, null, MapAndSignal((uint)result.ErrorCode));
if (result.Value is not { } symbol)
return (default, null, TwinCATStatusMapper.BadNodeIdUnknown);
return (default, null, null, TwinCATStatusMapper.BadNodeIdUnknown);
// Resolve aliases (T_MaxString → STRING(255)) so the guard sees the base type, and take the
// resolved type's byte size — a TwinCAT 2 member symbol's own ByteSize is under-reported.
var dataType = symbol.DataType?.Resolve();
var shape = TwinCATPlcSymbolShape.Classify(
dataType?.Name ?? symbol.TypeName, dataType?.Category, symbol.ByteSize, dataType?.ByteSize, symbol.TypeName);
return (shape, symbol, TwinCATStatusMapper.Good);
return (shape, DescribeArray(dataType), symbol, TwinCATStatusMapper.Good);
}
/// <summary>
/// The array facts <see cref="TwinCATTypeGuard.CheckArray"/> plans from: every dimension's element
/// count, the resolved array size, and the alias-resolved element type. Null for a non-array.
/// </summary>
private static TwinCATPlcArrayShape? DescribeArray(IDataType? dataType)
{
if (dataType is not IArrayType arrayType || arrayType.Dimensions is not { } dims) return null;
var element = arrayType.ElementType?.Resolve();
var elementShape = TwinCATPlcSymbolShape.Classify(
element?.Name ?? arrayType.ElementTypeName, element?.Category, element?.ByteSize ?? 0);
return new TwinCATPlcArrayShape([.. dims.Select(d => d.ElementCount)], dataType.ByteSize, elementShape);
}
/// <summary>
@@ -246,15 +257,6 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
? TwinCATStringCodec.DecodeWString(bytes)
: TwinCATStringCodec.DecodeString(bytes, utf8);
private static string StripBitSuffix(string symbolPath)
{
var lastDot = symbolPath.LastIndexOf('.');
if (lastDot < 0) return symbolPath;
return int.TryParse(symbolPath.AsSpan(lastDot + 1), out _)
? symbolPath[..lastDot]
: symbolPath;
}
/// <inheritdoc />
public async Task<uint> WriteValueAsync(
string symbolPath,
@@ -297,6 +299,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
string symbolPath,
TwinCATDataType type,
int? bitIndex,
int? arrayCount,
TimeSpan cycleTime,
int maxDelayMs,
Action<string, object?> onChange,
@@ -305,24 +308,59 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
var clrType = MapToClrType(type);
int[]? anyTypeArgs = null;
var stringsUtf8 = false;
if (bitIndex is null || type != TwinCATDataType.Bool)
TwinCATArrayLayout? arrayLayout = null;
var registerPath = symbolPath;
if (bitIndex is int bit && type == TwinCATDataType.Bool)
{
// ADS has no symbol for WORD.N (live on TC 2.11: DeviceSymbolNotFound for .ADSSTATE_RUN.0,
// which took the whole subscribe call down). Register the PARENT word as raw bytes at its
// resolved width and read the bit in OnAdsNotificationEx — the read path's model.
registerPath = TwinCATBitAccess.ParentPath(symbolPath);
var (parent, _, _, parentStatus) = await DescribeSymbolAsync(registerPath, cancellationToken)
.ConfigureAwait(false);
if (parentStatus != TwinCATStatusMapper.Good)
throw new InvalidOperationException(
$"Resolving {registerPath} for a notification failed with status 0x{parentStatus:X8}");
if (TwinCATBitAccess.CheckParent(bit, parent) is { } bitReason)
throw new TwinCATTypeMismatchException(symbolPath, bitReason);
clrType = typeof(byte[]);
anyTypeArgs = [parent.ByteSize];
}
else
{
// A notification carries raw bytes decoded as the DECLARED type, so a declared type that
// cannot decode the PLC symbol (width, float/integer or string kind differ) would publish
// wrong values at Good. Refuse it here; the driver isolates the one tag to Bad.
var (shape, _, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
var (shape, array, _, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
.ConfigureAwait(false);
if (symbolStatus != TwinCATStatusMapper.Good)
throw new InvalidOperationException(
$"Resolving {symbolPath} for a notification failed with status 0x{symbolStatus:X8}");
if (TwinCATTypeGuard.CheckNotification(type, shape) is { } reason)
if (arrayCount is int count)
{
// An array registers the whole planned layout (element stride × authored length, from the
// RESOLVED array type) as raw bytes and decodes it in OnAdsNotificationEx with the same
// TwinCATArrayCodec a poll read uses — so both paths publish the identical array.
if (TwinCATTypeGuard.CheckArray(type, count, shape, array, out var layout) is { } arrayReason)
throw new TwinCATTypeMismatchException(symbolPath, arrayReason);
if (TwinCATTypeGuard.CheckNotificationSize(layout) is { } sizeReason)
throw new TwinCATNotificationRefusedException(symbolPath, sizeReason, TwinCATStatusMapper.BadOutOfRange);
clrType = typeof(byte[]);
anyTypeArgs = [layout.ByteLength];
stringsUtf8 = StringsAreUtf8();
arrayLayout = layout;
}
else if (TwinCATTypeGuard.CheckNotification(type, shape) is { } reason)
{
throw new TwinCATTypeMismatchException(symbolPath, reason);
}
// A string has no fixed width: the "any" type string needs an explicit length (the
// Beckhoff marshaller throws ArgumentNullException without one), and a STRING(n) is n+1
// bytes with n chosen per declaration. Register the PLC's own byte width as raw bytes
// and decode them in OnAdsNotificationEx with the same rules as a read.
if (type is TwinCATDataType.String or TwinCATDataType.WString)
if (arrayLayout is null && type is TwinCATDataType.String or TwinCATDataType.WString)
{
clrType = typeof(byte[]);
anyTypeArgs = [shape.ByteSize];
@@ -337,23 +375,24 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
// with the handle as part of the event args so we use the handle as the correlation
// key into _notifications.
var result = await _client.AddDeviceNotificationExAsync(
symbolPath, settings, userData: null, clrType, anyTypeArgs, cancellationToken)
registerPath, settings, userData: null, clrType, anyTypeArgs, cancellationToken)
.ConfigureAwait(false);
if (result.ErrorCode != AdsErrorCode.NoError)
throw new InvalidOperationException(
$"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {symbolPath}");
$"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {registerPath}");
var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle)
{
StringsUtf8 = stringsUtf8,
ArrayLayout = arrayLayout,
};
_notifications[result.Handle] = reg;
return reg;
}
/// <summary>
/// Runs on the <see cref="AdsClient"/> AMS router thread. Does the cheap bit-extraction
/// decode then enqueues — no driver logic, no consumer callbacks — so a slow consumer
/// Runs on the <see cref="AdsClient"/> AMS router thread. Does the cheap decode (bit
/// extraction, string, array) then enqueues — no driver logic, no consumer callbacks — so a slow consumer
/// can never stall ADS notification delivery for the rest of the process.
/// Drops the notification (DropWrite) if the queue is saturated.
/// </summary>
@@ -361,8 +400,16 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
{
if (!_notifications.TryGetValue(args.Handle, out var reg)) return;
var value = args.Value;
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool)
value = ExtractBit(value, bit);
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool)
{
if (value is not byte[] word || bit / 8 >= word.Length) return;
value = TwinCATBitAccess.ReadBit(word, bit);
}
else if (reg.ArrayLayout is { } layout && value is byte[] arrayBytes)
{
try { value = TwinCATArrayCodec.Decode(reg.Type, arrayBytes, layout, reg.StringsUtf8); }
catch (ArgumentException) { return; } // a short buffer is never published as a short array
}
else if (value is byte[] raw && reg.Type is TwinCATDataType.String or TwinCATDataType.WString)
value = DecodeString(reg.Type, raw, reg.StringsUtf8);
_notificationQueue.Writer.TryWrite(new PendingNotification(reg, value));
@@ -615,6 +662,11 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
/// </summary>
public bool StringsUtf8 { get; init; }
/// <summary>
/// Gets the raw-bytes layout of an array notification, decoded by <see cref="TwinCATArrayCodec"/>; null for a scalar.
/// </summary>
public TwinCATArrayLayout? ArrayLayout { get; init; }
/// <summary>
/// Disposes the notification subscription.
/// </summary>
@@ -41,9 +41,11 @@ public interface ITwinCATClient : IDisposable
/// <param name="symbolPath">The ADS symbol path.</param>
/// <param name="type">The target data type.</param>
/// <param name="bitIndex">Optional bit index for bit extraction within a word.</param>
/// <param name="arrayCount">When non-null, read a 1-D array of this many <paramref name="type"/>
/// elements; the boxed value is the element-typed CLR array (<c>int[]</c> / <c>float[]</c> /
/// <c>bool[]</c> / <c>string[]</c> / …). When null, read a scalar (Phase 4c).</param>
/// <param name="arrayCount">When non-null, read an array of this many <paramref name="type"/>
/// elements (a multi-dimensional PLC array flattened in memory order; a count shorter than the PLC
/// array reads its leading sub-range); the boxed value is the element-typed CLR array
/// (<c>int[]</c> / <c>float[]</c> / <c>bool[]</c> / <c>string[]</c> / …) decoded by
/// <see cref="TwinCATArrayCodec"/>. When null, read a scalar.</param>
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
/// <returns>The boxed value read (or <c>null</c>) together with the mapped OPC UA status.</returns>
Task<(object? value, uint status)> ReadValueAsync(
@@ -87,6 +89,10 @@ public interface ITwinCATClient : IDisposable
/// <param name="symbolPath">ADS symbol path (e.g. <c>MAIN.bStart</c>).</param>
/// <param name="type">Declared type; drives the native layout + callback value boxing.</param>
/// <param name="bitIndex">For BOOL-within-word tags — the bit to extract from the parent word.</param>
/// <param name="arrayCount">When non-null (and no <paramref name="bitIndex"/>), register the whole
/// array: <paramref name="arrayCount"/> elements of <paramref name="type"/> as raw bytes, decoded per
/// notification into the same element-typed CLR array <see cref="ReadValueAsync"/> returns
/// (<see cref="TwinCATArrayCodec"/>). Null = a scalar.</param>
/// <param name="cycleTime">Minimum interval between change notifications (native-floor depends on target).</param>
/// <param name="maxDelayMs">Maximum batching delay in milliseconds — TwinCAT may coalesce
/// notifications up to this delay before pushing them. <c>0</c> = no batching, push
@@ -94,13 +100,18 @@ public interface ITwinCATClient : IDisposable
/// <param name="onChange">Invoked with <c>(symbolPath, boxedValue)</c> per notification.</param>
/// <param name="cancellationToken">Cancels the initial registration; does not tear down an established notification.</param>
/// <returns>A handle whose disposal tears the registered notification down.</returns>
/// <exception cref="TwinCATTypeMismatchException">The declared <paramref name="type"/> cannot decode
/// the PLC symbol (width, float/integer or string kind differ) — the caller isolates that one tag
/// to Bad rather than registering a notification that would publish wrong bytes at Good.</exception>
/// <exception cref="TwinCATNotificationRefusedException">The notification is refused for this one tag
/// — the declared <paramref name="type"/> / <paramref name="arrayCount"/> cannot decode the PLC
/// symbol (<see cref="TwinCATTypeMismatchException"/>: width, float/integer or string kind differ,
/// not an array, too long), or the array exceeds
/// <see cref="TwinCATTypeGuard.MaxNotificationArrayBytes"/>. The caller isolates that one tag to the
/// exception's status rather than registering a notification that would publish wrong bytes at
/// Good.</exception>
Task<ITwinCATNotificationHandle> AddNotificationAsync(
string symbolPath,
TwinCATDataType type,
int? bitIndex,
int? arrayCount,
TimeSpan cycleTime,
int maxDelayMs,
Action<string, object?> onChange,
@@ -0,0 +1,125 @@
using System.Buffers.Binary;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// Decodes a PLC array's raw bytes — laid out by <see cref="TwinCATTypeGuard.CheckArray"/> — into the
/// element-typed CLR array an array tag publishes (<c>bool[]</c>, <c>short[]</c>, <c>float[]</c>,
/// <c>string[]</c>, …). Both the poll read and the native notification decode here, so the two paths
/// publish identical values, element type and length by construction.
/// </summary>
/// <remarks>
/// The driver reads arrays as raw bytes rather than through the Beckhoff typed array read, which a live
/// TwinCAT 2.11 target showed failing for most real arrays (2026-10-07, Elma cleaner Z33856): the CLR
/// element type had to match the PLC's exactly (<c>ARRAY OF TIME</c> as <c>uint[]</c> threw), a
/// multi-dimensional array demanded a rank-N CLR array, and a dotted MEMBER array was sliced by the
/// under-reported symbol size (<c>ArgumentOutOfRangeException</c>). Elements are little-endian (every
/// TwinCAT target is x86/x64/ARM-LE); a STRING / WSTRING element is decoded by
/// <see cref="TwinCATStringCodec"/> within its fixed stride, so each ends at its own first NUL.
/// </remarks>
internal static class TwinCATArrayCodec
{
/// <summary>Decodes <paramref name="layout"/>.ElementCount elements of <paramref name="declared"/>.</summary>
/// <param name="declared">The tag's declared element type.</param>
/// <param name="bytes">The PLC bytes from the array's first element (at least <see cref="TwinCATArrayLayout.ByteLength"/>).</param>
/// <param name="layout">The planned layout.</param>
/// <param name="utf8">True when the target reports UTF-8 STRING data (else Windows-1252).</param>
/// <returns>The element-typed CLR array.</returns>
/// <exception cref="ArgumentException">The buffer is shorter than the layout.</exception>
public static Array Decode(TwinCATDataType declared, ReadOnlySpan<byte> bytes, TwinCATArrayLayout layout, bool utf8)
{
if (layout.ElementCount < 0 || layout.Stride <= 0 || bytes.Length < layout.ByteLength)
throw new ArgumentException(
$"An array buffer of {bytes.Length} B cannot hold {layout.ElementCount} × {layout.Stride} B elements",
nameof(bytes));
var n = layout.ElementCount;
var stride = layout.Stride;
switch (declared)
{
case TwinCATDataType.Bool:
{
var a = new bool[n];
for (var i = 0; i < n; i++) a[i] = bytes[i * stride] != 0;
return a;
}
case TwinCATDataType.SInt:
{
var a = new sbyte[n];
for (var i = 0; i < n; i++) a[i] = unchecked((sbyte)bytes[i * stride]);
return a;
}
case TwinCATDataType.USInt:
{
var a = new byte[n];
for (var i = 0; i < n; i++) a[i] = bytes[i * stride];
return a;
}
case TwinCATDataType.Int:
{
var a = new short[n];
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt16LittleEndian(bytes[(i * stride)..]);
return a;
}
case TwinCATDataType.UInt:
{
var a = new ushort[n];
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt16LittleEndian(bytes[(i * stride)..]);
return a;
}
case TwinCATDataType.DInt:
{
var a = new int[n];
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt32LittleEndian(bytes[(i * stride)..]);
return a;
}
case TwinCATDataType.UDInt or TwinCATDataType.Time or TwinCATDataType.Date
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay:
{
// TIME / DATE / DT / TOD publish their raw 32-bit count — the same CLR shape (uint) a
// scalar of those types publishes.
var a = new uint[n];
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt32LittleEndian(bytes[(i * stride)..]);
return a;
}
case TwinCATDataType.LInt:
{
var a = new long[n];
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt64LittleEndian(bytes[(i * stride)..]);
return a;
}
case TwinCATDataType.ULInt:
{
var a = new ulong[n];
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt64LittleEndian(bytes[(i * stride)..]);
return a;
}
case TwinCATDataType.Real:
{
var a = new float[n];
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadSingleLittleEndian(bytes[(i * stride)..]);
return a;
}
case TwinCATDataType.LReal:
{
var a = new double[n];
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadDoubleLittleEndian(bytes[(i * stride)..]);
return a;
}
case TwinCATDataType.String:
{
var a = new string[n];
for (var i = 0; i < n; i++) a[i] = TwinCATStringCodec.DecodeString(bytes.Slice(i * stride, stride), utf8);
return a;
}
case TwinCATDataType.WString:
{
var a = new string[n];
for (var i = 0; i < n; i++) a[i] = TwinCATStringCodec.DecodeWString(bytes.Slice(i * stride, stride));
return a;
}
default:
throw new NotSupportedException($"TwinCATDataType {declared} has no array element decoding");
}
}
}
@@ -0,0 +1,41 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// Serves a bit-indexed BOOL tag (<c>MAIN.Flags.3</c>, <c>.ADSSTATE_RUN.0</c>) from its PARENT word.
/// ADS has no symbol for <c>WORD.N</c> — live on a TwinCAT 2.11 target a notification registered on
/// <c>.ADSSTATE_RUN.0</c> failed with <c>DeviceSymbolNotFound</c> — so a native notification registers
/// the parent as raw bytes at its resolved width and reads the bit here, exactly as a read already
/// reads the parent word and extracts the bit.
/// </summary>
internal static class TwinCATBitAccess
{
/// <summary>The parent word's path: the bit path without its trailing <c>.N</c>.</summary>
/// <param name="symbolPath">The bit path (<c>MAIN.Flags.3</c>).</param>
/// <returns>The parent path, or <paramref name="symbolPath"/> when it has no numeric last segment.</returns>
public static string ParentPath(string symbolPath)
{
var lastDot = symbolPath.LastIndexOf('.');
if (lastDot < 0) return symbolPath;
return int.TryParse(symbolPath.AsSpan(lastDot + 1), out _) ? symbolPath[..lastDot] : symbolPath;
}
/// <summary>Checks that <paramref name="bit"/> addresses a bit of an integer parent word.</summary>
/// <param name="bit">The bit index.</param>
/// <param name="parent">The parent symbol's shape.</param>
/// <returns><c>null</c> when addressable; otherwise the reason.</returns>
public static string? CheckParent(int bit, TwinCATPlcSymbolShape parent)
{
if (parent.Kind != TwinCATPlcKind.Scalar || parent.IsFloat)
return $"bit {bit} of {parent.TypeName} ({parent.ByteSize} B) — only an integer word has addressable bits";
return bit >= 0 && bit < parent.ByteSize * 8
? null
: $"bit {bit} is outside the {parent.ByteSize * 8}-bit parent {parent.TypeName}";
}
/// <summary>Reads bit <paramref name="bit"/> of a little-endian parent word.</summary>
/// <param name="parent">The parent word's raw bytes.</param>
/// <param name="bit">The bit index (0 = least significant bit of the first byte).</param>
/// <returns>The bit's value.</returns>
public static bool ReadBit(ReadOnlySpan<byte> parent, int bit) =>
(parent[bit / 8] & (1 << (bit % 8))) != 0;
}
@@ -263,10 +263,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
// An array-typed tag (def.ArrayLength != null) drives a native 1-D ADS array read;
// the boxed result is an element-typed CLR array. Scalar tags pass null
// (scalar path) — bit-indexed BOOLs are inherently scalar so the client ignores
// arrayCount when a bitIndex is present (Phase 4c).
// An array-typed tag (def.ArrayLength != null) reads the array as raw bytes and decodes
// it with TwinCATArrayCodec — the same decode a native array notification runs — into an
// element-typed CLR array. Scalar tags pass null (scalar path) — bit-indexed BOOLs are
// inherently scalar so the client ignores arrayCount when a bitIndex is present.
var (value, status) = await client.ReadValueAsync(
symbolName, def.DataType, parsed?.BitIndex, def.ArrayLength, cancellationToken)
.ConfigureAwait(false);
@@ -279,7 +279,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
if (status == TwinCATStatusMapper.BadTypeMismatch)
_logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' refused '{Reference}': declared {DataType} " +
"cannot decode the PLC symbol '{Symbol}' (string/numeric kind mismatch or a reference/container)",
"cannot decode the PLC symbol '{Symbol}' (string/numeric kind mismatch, array element type " +
"or length mismatch, or a reference/container)",
_driverInstanceId, reference, def.DataType, symbolName);
else
_logger.LogWarning(
@@ -531,7 +532,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
// client swap unchanged — replay re-registers the SAME intent onto the new client.
var reg = new NativeRegistration(
Interlocked.Increment(ref _nextRegId), device, reference, symbolName,
def.DataType, bitIndex, publishingInterval,
def.DataType, bitIndex, def.ArrayLength, publishingInterval,
onChange: (_, value) => OnDataChange?.Invoke(this,
new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
value, TwinCATStatusMapper.Good, DateTime.UtcNow, DateTime.UtcNow))),
@@ -545,17 +546,19 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
{
await RegisterNotificationAsync(device, reg, cancellationToken).ConfigureAwait(false);
}
catch (TwinCATTypeMismatchException mismatch)
catch (TwinCATNotificationRefusedException refused)
{
// The declared type cannot decode this PLC symbol (ignitionoee TwinCAT-002/-008):
// isolate THIS tag to Bad — one WARN, one BadTypeMismatch snapshot — and keep the
// rest of the subscription. Its intent stays on the device with no live handle,
// so the probe-tick retry re-checks it after a PLC program change.
// The declared type / array length cannot decode this PLC symbol (ignitionoee
// TwinCAT-002/-008), or the array is over the native-notification size cap: isolate
// THIS tag to Bad — one WARN, one snapshot carrying the refusal's status
// (BadTypeMismatch / BadOutOfRange) — and keep the rest of the subscription. Its
// intent stays on the device with no live handle, so the probe-tick retry re-checks
// it after a PLC program change.
_logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' refused native notification for '{Reference}': {Reason}",
_driverInstanceId, reference, mismatch.Message);
_driverInstanceId, reference, refused.Message);
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
null, TwinCATStatusMapper.BadTypeMismatch, null, DateTime.UtcNow)));
null, refused.StatusCode, null, DateTime.UtcNow)));
}
registrations.Add(reg);
}
@@ -594,10 +597,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
try
{
newHandle = await client.AddNotificationAsync(
reg.SymbolName, reg.DataType, reg.BitIndex, reg.Interval,
reg.SymbolName, reg.DataType, reg.BitIndex, reg.ArrayLength, reg.Interval,
_options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false);
}
catch (TwinCATTypeMismatchException)
catch (TwinCATNotificationRefusedException)
{
// Keep the intent with no live handle (a "dead" registration) so the probe-tick
// retry re-checks it and UnsubscribeAsync still removes it; the caller isolates it.
@@ -659,6 +662,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
public TwinCATDataType DataType { get; }
/// <summary>Bit index for a BOOL-within-word symbol; null otherwise.</summary>
public int? BitIndex { get; }
/// <summary>Authored array length — the whole array is registered and published; null for a scalar.</summary>
public int? ArrayLength { get; }
/// <summary>Requested notification interval.</summary>
public TimeSpan Interval { get; }
/// <summary>Value-change callback (closes over the owning subscription handle + reference).</summary>
@@ -687,11 +692,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// <param name="symbolName">ADS symbol name (resolved from the symbol path).</param>
/// <param name="dataType">Declared data type.</param>
/// <param name="bitIndex">Bit index for a BOOL-within-word symbol; null otherwise.</param>
/// <param name="arrayLength">Authored array length for an array tag; null for a scalar.</param>
/// <param name="interval">Requested notification interval.</param>
/// <param name="onChange">Value-change callback (closes over the owning subscription handle + reference).</param>
/// <param name="onUnavailable">Bad-quality callback fired when the handle goes dead (05/STAB-16).</param>
public NativeRegistration(long id, DeviceState device, string reference, string symbolName,
TwinCATDataType dataType, int? bitIndex, TimeSpan interval, Action<string, object?> onChange,
TwinCATDataType dataType, int? bitIndex, int? arrayLength, TimeSpan interval, Action<string, object?> onChange,
Action onUnavailable)
{
Id = id;
@@ -700,6 +706,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
SymbolName = symbolName;
DataType = dataType;
BitIndex = bitIndex;
ArrayLength = arrayLength;
Interval = interval;
OnChange = onChange;
OnUnavailable = onUnavailable;
@@ -969,7 +976,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
try
{
var newHandle = await client.AddNotificationAsync(
reg.SymbolName, reg.DataType, reg.BitIndex, reg.Interval,
reg.SymbolName, reg.DataType, reg.BitIndex, reg.ArrayLength, reg.Interval,
_options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false);
var old = reg.SwapHandle(newHandle);
try { old?.Dispose(); } catch { /* dead handle from the disposed client */ }
@@ -1034,7 +1041,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
try
{
var newHandle = await client.AddNotificationAsync(
reg.SymbolName, reg.DataType, reg.BitIndex, reg.Interval,
reg.SymbolName, reg.DataType, reg.BitIndex, reg.ArrayLength, reg.Interval,
_options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false);
reg.SwapHandle(newHandle);
// STAB-17 ownership re-check — see ReplayNativeRegistrationsAsync.
@@ -0,0 +1,28 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// Thrown by <see cref="ITwinCATClient.AddNotificationAsync"/> when the client refuses to register a
/// native notification for one tag — its declared type cannot decode the PLC symbol
/// (<see cref="TwinCATTypeMismatchException"/>), or an array is too large for a native notification.
/// The driver isolates that one tag to <see cref="StatusCode"/> (one WARN) and keeps the rest of the
/// subscription; the intent stays registered so the probe-tick retry re-checks it.
/// </summary>
internal class TwinCATNotificationRefusedException : Exception
{
/// <summary>Initializes a new instance of the <see cref="TwinCATNotificationRefusedException"/> class.</summary>
/// <param name="symbolPath">The ADS symbol the tag names.</param>
/// <param name="reason">The one-line reason.</param>
/// <param name="statusCode">The OPC UA status the refused tag publishes.</param>
public TwinCATNotificationRefusedException(string symbolPath, string reason, uint statusCode)
: base($"TwinCAT symbol '{symbolPath}': {reason}")
{
SymbolPath = symbolPath;
StatusCode = statusCode;
}
/// <summary>Gets the ADS symbol the refused tag names.</summary>
public string SymbolPath { get; }
/// <summary>Gets the OPC UA status the refused tag publishes.</summary>
public uint StatusCode { get; }
}
@@ -24,7 +24,9 @@ internal enum TwinCATPlcKind
/// <param name="ByteSize">The symbol's byte size as the PLC reports it.</param>
/// <param name="IsFloat">True for a REAL / LREAL scalar.</param>
/// <param name="TypeName">The (alias-resolved) PLC type name, for log messages.</param>
internal readonly record struct TwinCATPlcSymbolShape(TwinCATPlcKind Kind, int ByteSize, bool IsFloat, string TypeName)
/// <param name="IsArray">True for an ARRAY (a <see cref="TwinCATPlcKind.NonScalar"/> with array category or name).</param>
internal readonly record struct TwinCATPlcSymbolShape(
TwinCATPlcKind Kind, int ByteSize, bool IsFloat, string TypeName, bool IsArray = false)
{
private static readonly Regex StringTypeName =
new(@"^(W?)STRING(\s*\(\s*\d+\s*\))?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
@@ -71,13 +73,39 @@ internal readonly record struct TwinCATPlcSymbolShape(TwinCATPlcKind Kind, int B
|| name.StartsWith("POINTER TO", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("REFERENCE TO", StringComparison.OrdinalIgnoreCase)
|| name.Contains("VAR_IN_OUT", StringComparison.OrdinalIgnoreCase))
return new(TwinCATPlcKind.NonScalar, byteSize, false, name);
return new(TwinCATPlcKind.NonScalar, byteSize, false, name,
IsArray: category == DataTypeCategory.Array || name.StartsWith("ARRAY", StringComparison.OrdinalIgnoreCase));
var mapped = AdsTwinCATClient.MapSymbolTypeName(name);
return new(TwinCATPlcKind.Scalar, byteSize, mapped is TwinCATDataType.Real or TwinCATDataType.LReal, name);
}
}
/// <summary>The facts about a PLC ARRAY symbol <see cref="TwinCATTypeGuard.CheckArray"/> plans a layout from.</summary>
/// <param name="DimensionLengths">The element count of each dimension (lower bounds play no part: the
/// driver publishes a 0-based, flattened array in PLC memory order).</param>
/// <param name="ByteSize">The array's byte size, from its RESOLVED data type (a TwinCAT 2 member symbol
/// under-reports its own size).</param>
/// <param name="Element">The (alias-resolved) element type, classified like a scalar symbol.</param>
internal readonly record struct TwinCATPlcArrayShape(IReadOnlyList<int> DimensionLengths, int ByteSize, TwinCATPlcSymbolShape Element)
{
/// <summary>Gets the total element count across every dimension (0 when any dimension is empty).</summary>
public long ElementCount => DimensionLengths.Aggregate(1L, (total, length) => total * Math.Max(0, length));
}
/// <summary>
/// How an array tag is read from the PLC's raw bytes: <paramref name="ElementCount"/> elements at a
/// fixed <paramref name="Stride"/>, starting at the array's first byte.
/// </summary>
/// <param name="ElementCount">The number of elements published (the authored <c>ArrayLength</c>).</param>
/// <param name="Stride">The bytes per element: the element type's width, or n+1 / 2(n+1) for a
/// <c>STRING(n)</c> / <c>WSTRING(n)</c>.</param>
internal readonly record struct TwinCATArrayLayout(int ElementCount, int Stride)
{
/// <summary>Gets the number of bytes the read or notification covers.</summary>
public int ByteLength => ElementCount * Stride;
}
/// <summary>
/// Decides whether a tag's DECLARED <see cref="TwinCATDataType"/> may decode the PLC symbol it names —
/// the port of the ignitionoee TwinCAT driver's extra-type reconciliation (TwinCAT-002, -008). A
@@ -90,11 +118,14 @@ internal readonly record struct TwinCATPlcSymbolShape(TwinCATPlcKind Kind, int B
/// <list type="bullet">
/// <item><b>Read</b> (<see cref="CheckRead"/>) goes through the Beckhoff symbol cache, which
/// decodes by the PLC's OWN type and converts the value to the declared CLR type — a numeric
/// pair is value-converted, never reinterpreted. Only string-ness can go wrong there: a
/// numeric tag over a PLC string got a <c>string</c> object back at Good.</item>
/// pair is value-converted, never reinterpreted. Only string-ness can go wrong there (a
/// numeric tag over a PLC string got a <c>string</c> object back at Good), plus a scalar over
/// an ARRAY, which the read cannot convert at all.</item>
/// <item><b>Notification</b> (<see cref="CheckNotification"/>) registers raw bytes decoded as the
/// DECLARED type, so a width mismatch truncates/over-reads and a same-width float/integer clash
/// reinterprets the bit pattern — both at Good.</item>
/// <item><b>Array tags</b> (<see cref="CheckArray"/>) are read AND notified as raw bytes, so the
/// element gets the notification rules on both paths.</item>
/// </list>
/// String-ness is a KIND (TwinCAT-008): a STRING/WSTRING tag matches only a PLC string of the SAME
/// kind, at any declared length (0.19.2). A same-width, same-kind numeric divergence
@@ -117,6 +148,9 @@ internal static class TwinCATTypeGuard
: $"declared {declared} but the PLC symbol is {plc.TypeName} ({plc.ByteSize} B) — a string tag " +
$"matches only a PLC {declaredKind.Value.ToString().ToUpperInvariant()} of any length";
}
if (plc.IsArray)
return $"declared scalar {declared} but the PLC symbol is the array {plc.TypeName} ({plc.ByteSize} B) — " +
"author it as an array tag (isArray + arrayLength) or address one element (Arr[i])";
return plc.Kind is TwinCATPlcKind.String or TwinCATPlcKind.WString
? $"declared {declared} but the PLC symbol is the string {plc.TypeName} — declare it String/WString"
: null;
@@ -144,6 +178,72 @@ internal static class TwinCATTypeGuard
: $"declared {declared} but the PLC symbol is {plc.TypeName} — float/integer kinds differ";
}
/// <summary>
/// The largest array a native notification registers, in bytes: 128 KiB — the largest registration
/// proven on a real target (a TwinCAT 2.11 <c>ARRAY [0..65535] OF UINT</c>, live 2026-10-07; ADS
/// itself documents no notification size limit). The PLC compares the whole buffer every cycle for
/// an on-change notification, and every change is copied through the router and the driver's
/// notification queue, so a bigger array is refused rather than registered blind.
/// </summary>
public const int MaxNotificationArrayBytes = 128 * 1024;
/// <summary>
/// Checks an ARRAY tag — its declared element type and authored <c>ArrayLength</c> — against a PLC
/// array, and plans the raw-bytes layout both the poll read and the native notification decode
/// with <see cref="TwinCATArrayCodec"/>.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item>The element is checked like a scalar notification (<see cref="CheckNotification"/>):
/// the bytes are decoded as the DECLARED element type, so width, float/integer and string kind
/// must agree. Struct / FB / pointer / nested-array (jagged) elements are refused.</item>
/// <item>A multi-dimensional array is published flattened in PLC memory order (the last index
/// varies fastest); its <c>ArrayLength</c> is the total element count. Lower bounds play no
/// part — the published index is 0-based.</item>
/// <item>An authored <c>ArrayLength</c> SHORTER than the PLC array reads the leading sub-range
/// (the first N elements, N × stride bytes from the array start); a LONGER one is refused, since
/// it would read past the array.</item>
/// </list>
/// </remarks>
/// <param name="declared">The tag's declared element type.</param>
/// <param name="declaredLength">The tag's authored <c>ArrayLength</c>.</param>
/// <param name="symbol">The PLC symbol's shape (decides a non-array symbol; names it in messages).</param>
/// <param name="array">The PLC array's shape, or null when the symbol is not an array.</param>
/// <param name="layout">The planned layout when compatible; otherwise default.</param>
/// <returns><c>null</c> when compatible; otherwise the reason.</returns>
public static string? CheckArray(TwinCATDataType declared, int declaredLength, TwinCATPlcSymbolShape symbol,
TwinCATPlcArrayShape? array, out TwinCATArrayLayout layout)
{
layout = default;
if (declaredLength <= 0)
return $"declared an array of {declaredLength} {declared} elements — an array tag needs arrayLength >= 1";
if (array is not { } plc || symbol.TypeName.Contains("VAR_IN_OUT", StringComparison.OrdinalIgnoreCase))
return $"declared an array of {declaredLength} {declared} but the PLC symbol is {symbol.TypeName} " +
$"({symbol.ByteSize} B) — not an array";
if (CheckNotification(declared, plc.Element) is { } elementReason)
return $"{symbol.TypeName}: element {elementReason}";
var stride = plc.Element.ByteSize;
var count = plc.ElementCount;
if (stride <= 0 || count <= 0 || count * stride != plc.ByteSize)
return $"{symbol.TypeName} is {plc.ByteSize} B, not {count} × {stride} B elements — not a packed array";
if (declaredLength > count)
return $"declared arrayLength {declaredLength} but the PLC array {symbol.TypeName} has {count} elements";
layout = new TwinCATArrayLayout(declaredLength, stride);
return null;
}
/// <summary>Checks a planned array layout against <see cref="MaxNotificationArrayBytes"/>.</summary>
/// <param name="layout">The planned layout.</param>
/// <returns><c>null</c> when it fits; otherwise the reason.</returns>
public static string? CheckNotificationSize(TwinCATArrayLayout layout) =>
layout.ByteLength <= MaxNotificationArrayBytes
? null
: $"{layout.ElementCount} elements ({layout.ByteLength} B) exceed the {MaxNotificationArrayBytes} B " +
"native-notification cap — author a shorter arrayLength or subscribe with UseNativeNotifications=false";
/// <summary>The byte width the declared type decodes from a notification buffer.</summary>
/// <param name="type">The declared type.</param>
/// <returns>The width in bytes.</returns>
@@ -2,21 +2,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// <summary>
/// Thrown by <see cref="ITwinCATClient.AddNotificationAsync"/> when a tag's declared
/// <see cref="TwinCATDataType"/> cannot decode the PLC symbol it names (see
/// <see cref="TwinCATTypeGuard"/>). The driver isolates the one tag to
/// <see cref="TwinCATStatusMapper.BadTypeMismatch"/> and keeps the rest of the subscription.
/// <see cref="TwinCATDataType"/> (or, for an array tag, its element type / <c>ArrayLength</c>) cannot
/// decode the PLC symbol it names (see <see cref="TwinCATTypeGuard"/>). The driver isolates the one
/// tag to <see cref="TwinCATStatusMapper.BadTypeMismatch"/> and keeps the rest of the subscription.
/// </summary>
internal sealed class TwinCATTypeMismatchException : Exception
internal sealed class TwinCATTypeMismatchException : TwinCATNotificationRefusedException
{
/// <summary>Initializes a new instance of the <see cref="TwinCATTypeMismatchException"/> class.</summary>
/// <param name="symbolPath">The ADS symbol the tag names.</param>
/// <param name="reason">The guard's one-line reason.</param>
public TwinCATTypeMismatchException(string symbolPath, string reason)
: base($"TwinCAT symbol '{symbolPath}': {reason}")
: base(symbolPath, reason, TwinCATStatusMapper.BadTypeMismatch)
{
SymbolPath = symbolPath;
}
/// <summary>Gets the ADS symbol the refused tag names.</summary>
public string SymbolPath { get; }
}
@@ -35,10 +35,11 @@
<div class="col-md-3">
<label class="form-label" for="abcipTransport">Transport</label>
<InputSelect @bind-Value="_form.Transport" @bind-Value:after="EmitAsync" class="form-select form-select-sm" id="abcipTransport">
<option value="@AbCipTransport.Libplctag">libplctag (default)</option>
<option value="">Default (node setting; Native unless pinned)</option>
<option value="@AbCipTransport.Native">Native (managed)</option>
<option value="@AbCipTransport.Libplctag">libplctag (fallback)</option>
</InputSelect>
<div class="form-text">Which protocol stack talks to the controller. Takes effect on the next deploy.</div>
<div class="form-text">Which protocol stack talks to the controller. Default omits the setting so the node-level <code>Drivers:AbCip:DefaultTransport</code> picks (Native unless an operator pinned it); takes effect on the next deploy.</div>
</div>
</div>
</div>
@@ -175,7 +176,10 @@
{
// Operation
public int TimeoutSeconds { get; set; } = 2;
public AbCipTransport Transport { get; set; } = AbCipTransport.Libplctag;
// Null = "Default": the transport key is OMITTED on save so the driver resolves it (and the AbLegacy
// PLC-5 / LogixPccc exclusion still applies). Never re-emit a concrete default here — that would pin
// every edited driver to it.
public AbCipTransport? Transport { get; set; }
public bool EnableControllerBrowse { get; set; } = false;
public bool EnableDeclarationOnlyUdtGrouping { get; set; } = false;
@@ -22,10 +22,11 @@
<div class="col-md-3">
<label class="form-label" for="ablegacyTransport">Transport</label>
<InputSelect @bind-Value="_form.Transport" @bind-Value:after="EmitAsync" class="form-select form-select-sm" id="ablegacyTransport">
<option value="@AbLegacyTransport.Libplctag">libplctag (default)</option>
<option value="@AbLegacyTransport.Native">Native (managed)</option>
<option value="">Default (libplctag)</option>
<option value="@AbLegacyTransport.Native">Native (managed, opt-in)</option>
<option value="@AbLegacyTransport.Libplctag">libplctag</option>
</InputSelect>
<div class="form-text">Which protocol stack talks to the controller. Takes effect on the next deploy.</div>
<div class="form-text">Which protocol stack talks to the controller. Default omits the setting (libplctag). Native is opt-in: wire-proven only for N-file reads/writes and F8 reads. Takes effect on the next deploy.</div>
</div>
</div>
</div>
@@ -140,7 +141,10 @@
{
// Operation
public int TimeoutSeconds { get; set; } = 2;
public AbLegacyTransport Transport { get; set; } = AbLegacyTransport.Libplctag;
// Null = "Default": the transport key is OMITTED on save so the driver resolves it (and the AbLegacy
// PLC-5 / LogixPccc exclusion still applies). Never re-emit a concrete default here — that would pin
// every edited driver to it.
public AbLegacyTransport? Transport { get; set; }
// Probe
public bool ProbeEnabled { get; set; } = true;
@@ -0,0 +1,58 @@
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
/// <summary>
/// Node-level defaults for the AbCip driver (final review I1), bound from <c>Drivers:AbCip</c>. Lets an
/// operator pin the transport an AbCip driver with NO <c>transport</c> key runs on — at upgrade time,
/// without editing (or being able to edit, #576) every driver config. An explicit per-driver
/// <c>transport</c> key still wins. Overridable by environment variable
/// <c>Drivers__AbCip__DefaultTransport</c>.
/// </summary>
public sealed class AbCipDriverDefaultsOptions
{
/// <summary>The configuration section path.</summary>
public const string SectionName = "Drivers:AbCip";
/// <summary>
/// <c>Native</c> (the default) or <c>Libplctag</c>, case-insensitive. Blank/absent = <c>Native</c>.
/// Any other value fails host start (<see cref="AbCipDriverDefaultsOptionsValidator"/>).
/// </summary>
public string? DefaultTransport { get; set; } = nameof(AbCipTransport.Native);
/// <summary>
/// The parsed default: blank resolves to the compiled <see cref="AbCipTransportResolver.Default"/>.
/// Throws on an unparseable value — unreachable at runtime because the validator fails host start first.
/// </summary>
/// <returns>The node-level default transport.</returns>
public AbCipTransport ResolveDefaultTransport()
{
if (string.IsNullOrWhiteSpace(DefaultTransport)) return AbCipTransportResolver.Default;
return AbCipTransportResolver.TryParse(DefaultTransport, out var t)
? t
: throw new InvalidOperationException(AbCipDriverDefaultsOptionsValidator.Message(DefaultTransport));
}
}
/// <summary>Fail-fast startup validator for <see cref="AbCipDriverDefaultsOptions"/>.</summary>
public sealed class AbCipDriverDefaultsOptionsValidator : OptionsValidatorBase<AbCipDriverDefaultsOptions>
{
/// <summary>The operator-facing failure text for a bad value.</summary>
/// <param name="value">The rejected value.</param>
/// <returns>The message.</returns>
internal static string Message(string? value) =>
$"Drivers:AbCip:DefaultTransport is '{value}' — expected one of " +
$"{string.Join(", ", Enum.GetNames<AbCipTransport>())} (case-insensitive). It selects the transport an " +
"AbCip driver with no \"transport\" key runs on; set it via appsettings or the environment variable " +
"Drivers__AbCip__DefaultTransport.";
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, AbCipDriverDefaultsOptions options)
{
builder.RequireThat(
string.IsNullOrWhiteSpace(options.DefaultTransport)
|| AbCipTransportResolver.TryParse(options.DefaultTransport, out _),
Message(options.DefaultTransport));
}
}
@@ -1,10 +1,12 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
namespace ZB.MOM.WW.OtOpcUa.Host.Drivers;
@@ -78,7 +80,11 @@ public static class DriverFactoryBootstrap
// whole mechanism exists to prevent, and it would fail as a plant outage rather than as a
// startup error.
var primaryRoleSource = sp.GetRequiredService<IDriverPrimaryRoleSource>();
Register(registry, loggerFactory, secretResolver, primaryRoleSource, scriptRoot);
// Final review I2: the native AB transport's process-wide pool logs options conflicts and close
// failures; without this it writes to NullLogger in production.
ConfigureAbTransportLogging(sp);
Register(registry, loggerFactory, secretResolver, primaryRoleSource, ResolveAbCipDefaultTransport(sp),
scriptRoot);
return registry;
});
services.AddSingleton<IDriverFactory>(sp =>
@@ -140,8 +146,21 @@ public static class DriverFactoryBootstrap
public static IServiceCollection AddOtOpcUaDriverProbes(this IServiceCollection services)
{
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, ModbusProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, AbCipProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, AbLegacyProbe>());
// Factory-built so the probe resolves an absent "transport" key to the SAME node default the driver
// factory uses (Drivers:AbCip:DefaultTransport, final review I1) — Test Connect probes the stack the
// driver would run. The generic factory overload keeps AbCipProbe as the implementation type, which
// TryAddEnumerable de-dups on.
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, AbCipProbe>(
sp =>
{
ConfigureAbTransportLogging(sp); // admin-only nodes reach the pool through the probes alone
return new AbCipProbe(tagFactory: null, defaultTransport: ResolveAbCipDefaultTransport(sp));
}));
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, AbLegacyProbe>(sp =>
{
ConfigureAbTransportLogging(sp);
return new AbLegacyProbe();
}));
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, S7Probe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, TwinCATProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, FocasProbe>());
@@ -155,6 +174,29 @@ public static class DriverFactoryBootstrap
return services;
}
/// <summary>
/// Gives the native AB transport's process-wide <c>DeviceSessionPool.Shared</c> the host logger factory
/// (final review I2). Called from every DI factory that can put the pool to use on this node (the driver
/// registry, and the AbCip/AbLegacy probes for admin-only nodes); idempotent. A container without an
/// <see cref="ILoggerFactory"/> leaves the pool on its null logger.
/// </summary>
/// <param name="sp">The service provider.</param>
internal static void ConfigureAbTransportLogging(IServiceProvider sp)
{
if (sp.GetService<ILoggerFactory>() is { } loggerFactory)
Driver.Ab.Transport.DeviceSessionPool.ConfigureSharedLogger(loggerFactory);
}
/// <summary>
/// The node-level AbCip default transport (<c>Drivers:AbCip:DefaultTransport</c>, bound + validated in
/// Program.cs). A container without the options bound (test graphs) gets the compiled default.
/// </summary>
/// <param name="sp">The service provider.</param>
/// <returns>The transport an AbCip driver with no <c>transport</c> key runs on.</returns>
internal static Driver.AbCip.AbCipTransport ResolveAbCipDefaultTransport(IServiceProvider sp) =>
sp.GetService<IOptions<AbCipDriverDefaultsOptions>>()?.Value.ResolveDefaultTransport()
?? Driver.AbCip.AbCipTransportResolver.Default;
/// <summary>
/// Invoke every cross-platform driver's <c>Register</c> extension. New driver assemblies
/// get added here — one line per type. ShouldStub() in <c>DriverInstanceActor</c> still
@@ -166,9 +208,10 @@ public static class DriverFactoryBootstrap
ILoggerFactory? loggerFactory,
ISecretResolver secretResolver,
IDriverPrimaryRoleSource primaryRoleSource,
Driver.AbCip.AbCipTransport abCipDefaultTransport,
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
{
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry, loggerFactory, abCipDefaultTransport);
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
@@ -175,6 +175,13 @@ builder.Services.AddOtOpcUaCluster(builder.Configuration);
// the startup check (AddOtOpcUaAuth binds LdapOptions but does not attach a validator).
builder.Services.AddValidatedOptions<LdapOptions, LdapOptionsValidator>(builder.Configuration, LdapOptions.SectionName);
// Node-level AbCip default transport (final review I1): the transport an AbCip driver with NO "transport"
// key runs on. Unconditional because BOTH consumers need it — the driver factory (driver role) and the
// Test Connect probe (admin role, which must probe the stack the driver would run). A bad value fails host
// start here rather than silently falling back.
builder.Services.AddValidatedOptions<AbCipDriverDefaultsOptions, AbCipDriverDefaultsOptionsValidator>(
builder.Configuration, AbCipDriverDefaultsOptions.SectionName);
if (hasDriver)
{
builder.Services.AddOtOpcUaRuntime();
@@ -14,26 +14,34 @@ public sealed class AbCipTransportOptionTests
private sealed class CapturingCommand : AbCipCommandBase
{
public static AbCipTransport? Captured;
public static bool Ran;
public override ValueTask ExecuteAsync(IConsole console)
{
Ran = true;
Captured = BuildOptions([]).Transport;
return default;
}
}
/// <summary>Verifies the default is libplctag when the flag is absent.</summary>
/// <summary>
/// With no flag the CLI leaves the option null ("use the default"), and the driver's resolver makes
/// that Native — the same path an absent <c>transport</c> key takes in a deployed config.
/// </summary>
[Fact]
public async Task Transport_defaults_to_libplctag()
public async Task Transport_defaults_to_the_resolver_default_native()
{
CapturingCommand.Captured = null;
CapturingCommand.Ran = false;
using var console = new FakeInMemoryConsole();
var app = new CliApplicationBuilder().AddCommand(typeof(CapturingCommand)).UseConsole(console).Build();
var exit = await app.RunAsync(["capture-transport", "--gateway", "ab://1.2.3.4/1,0"]);
exit.ShouldBe(0, console.ReadErrorString());
CapturingCommand.Captured.ShouldBe(AbCipTransport.Libplctag);
CapturingCommand.Ran.ShouldBeTrue();
CapturingCommand.Captured.ShouldBeNull();
AbCipTransportResolver.Resolve(CapturingCommand.Captured).ShouldBe(AbCipTransport.Native);
}
/// <summary>Verifies CliFx parses <c>--transport</c> (case-insensitive) into the options.</summary>
@@ -41,8 +49,10 @@ public sealed class AbCipTransportOptionTests
[InlineData("native", AbCipTransport.Native)]
[InlineData("Native", AbCipTransport.Native)]
[InlineData("libplctag", AbCipTransport.Libplctag)]
public async Task Transport_flag_is_parsed_by_the_cli(string value, AbCipTransport expected)
[InlineData("default", null)]
public async Task Transport_flag_is_parsed_by_the_cli(string value, AbCipTransport? expected)
{
CapturingCommand.Ran = false;
CapturingCommand.Captured = null;
using var console = new FakeInMemoryConsole();
var app = new CliApplicationBuilder().AddCommand(typeof(CapturingCommand)).UseConsole(console).Build();
@@ -50,6 +60,7 @@ public sealed class AbCipTransportOptionTests
var exit = await app.RunAsync(["capture-transport", "--gateway", "ab://1.2.3.4/1,0", "--transport", value]);
exit.ShouldBe(0, console.ReadErrorString());
CapturingCommand.Ran.ShouldBeTrue();
CapturingCommand.Captured.ShouldBe(expected);
}
}
@@ -161,42 +161,80 @@ public sealed class BuildOptionsTests
options.Devices[0].PlcFamily.ShouldBe(AbLegacyPlcFamily.Plc5);
}
/// <summary>Verifies the transport defaults to libplctag and --transport reaches the options.</summary>
[Fact]
public void BuildOptions_defaults_to_libplctag_and_forwards_transport()
/// <summary>
/// With no <c>--transport</c> the CLI leaves the option null and the driver's resolver decides: libplctag
/// for every family (final review I3; Native is opt-in). An explicit choice is forwarded verbatim and wins.
/// </summary>
[Theory]
[InlineData(AbLegacyPlcFamily.Slc500, AbLegacyTransport.Libplctag)]
[InlineData(AbLegacyPlcFamily.MicroLogix, AbLegacyTransport.Libplctag)]
[InlineData(AbLegacyPlcFamily.Plc5, AbLegacyTransport.Libplctag)]
[InlineData(AbLegacyPlcFamily.LogixPccc, AbLegacyTransport.Libplctag)]
public void BuildOptions_default_transport_goes_through_the_resolver(AbLegacyPlcFamily family, AbLegacyTransport expected)
{
var def = new TestCommand { Gateway = "ab://192.168.1.20/1,0" };
def.Build([]).Transport.ShouldBe(AbLegacyTransport.Libplctag);
var options = new TestCommand { Gateway = "ab://192.168.1.20/1,0", PlcType = family }.Build([]);
options.Transport.ShouldBeNull();
AbLegacyTransportResolver.Resolve(options).ShouldBe(expected);
var native = new TestCommand { Gateway = "ab://192.168.1.20/1,0", Transport = AbLegacyTransport.Native };
native.Build([]).Transport.ShouldBe(AbLegacyTransport.Native);
var native = new TestCommand
{
Gateway = "ab://192.168.1.20/1,0", PlcType = family, Transport = AbLegacyCliTransport.Native,
}.Build([]);
native.Transport.ShouldBe(AbLegacyTransport.Native);
AbLegacyTransportResolver.Resolve(native).ShouldBe(AbLegacyTransport.Native);
}
/// <summary>Verifies CliFx really parses <c>--transport native</c> (case-insensitive) into the property.</summary>
/// <summary>
/// End to end through CliFx: no <c>--transport</c> resolves to libplctag for every <c>--plc-type</c>
/// (final review I3). The slc500 / micrologix rows are the falsification rows — they resolved Native
/// before the revert.
/// </summary>
[Theory]
[InlineData("plc5", AbLegacyTransport.Libplctag)]
[InlineData("logixpccc", AbLegacyTransport.Libplctag)]
[InlineData("slc500", AbLegacyTransport.Libplctag)]
[InlineData("micrologix", AbLegacyTransport.Libplctag)]
public async Task Plc_type_without_transport_flag_resolves_through_the_resolver(string plcType, AbLegacyTransport expected)
{
CapturingCommand.Options = null;
using var console = new FakeInMemoryConsole();
var app = new CliApplicationBuilder().AddCommand(typeof(CapturingCommand)).UseConsole(console).Build();
var exit = await app.RunAsync(["capture-transport", "--gateway", "ab://1.2.3.4/1,0", "--plc-type", plcType]);
exit.ShouldBe(0, console.ReadErrorString());
CapturingCommand.Options.ShouldNotBeNull();
CapturingCommand.Options.Transport.ShouldBeNull();
AbLegacyTransportResolver.Resolve(CapturingCommand.Options).ShouldBe(expected);
}
/// <summary>Verifies CliFx really parses <c>--transport</c> (case-insensitive) into the property.</summary>
[Theory]
[InlineData("native", AbLegacyTransport.Native)]
[InlineData("Native", AbLegacyTransport.Native)]
[InlineData("libplctag", AbLegacyTransport.Libplctag)]
public async Task Transport_flag_is_parsed_by_the_cli(string value, AbLegacyTransport expected)
[InlineData("default", null)]
public async Task Transport_flag_is_parsed_by_the_cli(string value, AbLegacyTransport? expected)
{
CapturingCommand.Captured = null;
CapturingCommand.Options = null;
using var console = new FakeInMemoryConsole();
var app = new CliApplicationBuilder().AddCommand(typeof(CapturingCommand)).UseConsole(console).Build();
var exit = await app.RunAsync(["capture-transport", "--gateway", "ab://1.2.3.4/1,0", "--transport", value]);
exit.ShouldBe(0, console.ReadErrorString());
CapturingCommand.Captured.ShouldBe(expected);
CapturingCommand.Options.ShouldNotBeNull();
CapturingCommand.Options.Transport.ShouldBe(expected);
}
[Command("capture-transport")]
private sealed class CapturingCommand : AbLegacyCommandBase
{
public static AbLegacyTransport? Captured;
public static AbLegacyDriverOptions? Options;
public override System.Threading.Tasks.ValueTask ExecuteAsync(IConsole console)
{
Captured = BuildOptions([]).Transport;
Options = BuildOptions([]);
return default;
}
}
@@ -44,7 +44,7 @@ public static class KnownProfiles
public static readonly AbServerProfile CompactLogix = new(
Family: AbCipPlcFamily.CompactLogix,
ComposeProfile: "compactlogix",
Notes: "ab_server doesn't enforce the narrower ConnectionSize; driver-side profile caps it per PR 10.");
Notes: "Large Forward Open (4002) like libplctag since T21; ab_server accepts any size, so the regular-open fallback is unit-tested only.");
/// <summary>Gets the Micro800 profile.</summary>
public static readonly AbServerProfile Micro800 = new(
@@ -11,7 +11,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
internal sealed record SelectedTag(string Name, AbCipDataType? Type, int ElementCount, bool IsArray, string Origin);
/// <summary>What one transport observed for one tag.</summary>
internal sealed record ReadObservation(int Status, string? Error, byte[]? Raw, object? Decoded, string? DecodeError = null)
/// <param name="TypeCode">The CIP type code the reply carried, where the transport exposes it (native only: libplctag.NET
/// 1.5.2 does not surface it).</param>
internal sealed record ReadObservation(
int Status, string? Error, byte[]? Raw, object? Decoded, string? DecodeError = null, ushort? TypeCode = null)
{
public bool Succeeded => Status == (int)AbStatus.Ok && Error is null;
}
@@ -27,6 +30,13 @@ internal sealed class TransportObservations
/// <summary>Template ids that were queued but not read because <c>AB_DIFF_MAX_TEMPLATES</c> was reached.</summary>
public int TemplatesTruncated { get; set; }
public Dictionary<string, ReadObservation> Reads { get; } = new(StringComparer.Ordinal);
/// <summary>
/// libplctag only: a SECOND paced read of every selected tag, taken after the first pass and before native
/// starts (T21 press pass 1). A tag whose two reads differ is live — its value moves on its own — and is
/// compared with native structurally, not by value. Empty on the native side.
/// </summary>
public Dictionary<string, ReadObservation> ProbeReads { get; } = new(StringComparer.Ordinal);
}
/// <summary>The outcome of one whole differential run.</summary>
@@ -40,12 +50,27 @@ internal sealed class DifferentialResult
/// <summary>Native Forward Opens counted between the counter reset and the anchor release.</summary>
public required int NativeForwardOpens { get; init; }
/// <summary>Forward Close frames the native side put on the wire (sent) and got a reply to (acked), over the
/// same window as <see cref="NativeForwardOpens"/> — the anchor's release included (T21 review r1 M9).</summary>
public required NativeCloses NativeForwardCloses { get; init; }
/// <summary>TCP connections to the endpoint once the native session was released and drained (null when unobservable).</summary>
public required int? ConnectionsAfterNative { get; init; }
/// <summary>TCP connections to the endpoint before libplctag started, and once its handles were disposed.</summary>
public required int? ConnectionsBaseline { get; init; }
public required int? ConnectionsBeforeNative { get; init; }
public required TimeSpan LibplctagDrainWait { get; init; }
/// <summary>The endpoint's resolved addresses, and this machine's connections to it before the run (null when
/// unobservable). Reused by the latency comparison's barriers, which must not start alongside a session.</summary>
public required IReadOnlySet<IPAddress> Addresses { get; init; }
public required IReadOnlySet<IPEndPoint>? BaselineEndpoints { get; init; }
}
/// <summary>Forward Close frames the native side sent, and how many of them got a reply within their 1 s budget.</summary>
internal readonly record struct NativeCloses(int Sent, int Acked);
/// <summary>
/// The run stopped at its first connection-level failure, or when the native side opened more than once.
/// Thrown, never swallowed: every live test then fails with this message.
@@ -98,11 +123,14 @@ internal static class DifferentialRun
// ---- Barrier: libplctag's session must be gone before the native side connects. ----
var drain = Stopwatch.StartNew();
var beforeNative = await WaitForLibplctagSessionToCloseAsync(target, addresses, baseline, ct).ConfigureAwait(false);
var beforeNative = await WaitForSessionsToCloseAsync(target, addresses, baseline, "libplctag", ct).ConfigureAwait(false);
drain.Stop();
// ---- Phase 2: native only, every operation sharing ONE connection held open by the anchor. ----
var (native, opens) = await RunNativePhaseAsync(target, selection, ct).ConfigureAwait(false);
var (native, opens, closes) = await RunNativePhaseAsync(target, selection, ct).ConfigureAwait(false);
// ---- Barrier: the native session must be gone too (its Forward Close is awaited in the anchor's release). ----
var afterNative = await WaitForSessionsToCloseAsync(target, addresses, baseline, "native", ct).ConfigureAwait(false);
return new DifferentialResult
{
@@ -111,9 +139,13 @@ internal static class DifferentialRun
Native = native,
Selection = selection,
NativeForwardOpens = opens,
NativeForwardCloses = closes,
ConnectionsAfterNative = afterNative,
ConnectionsBaseline = baseline?.Count,
ConnectionsBeforeNative = beforeNative,
LibplctagDrainWait = drain.Elapsed,
Addresses = addresses,
BaselineEndpoints = baseline,
};
}
@@ -123,35 +155,76 @@ internal static class DifferentialRun
/// a fresh session per tag; an anchor handle on the listing pseudo-tag, held for the whole phase, keeps
/// every libplctag operation on ONE session — the mirror of the native anchor lease.
/// </summary>
internal static async Task<(TransportObservations Observations, IReadOnlyList<SelectedTag> Selection)> RunLibplctagPhaseAsync(
internal static Task<(TransportObservations Observations, IReadOnlyList<SelectedTag> Selection)> RunLibplctagPhaseAsync(
DifferentialTarget target, CancellationToken ct)
{
const string phase = "libplctag";
var lib = new TransportObservations();
var factories = AbCipTransportFactories.For(AbCipTransport.Libplctag);
using var anchor = factories.Tags.Create(target.Params("@tags"));
return WithLibplctagAnchorAsync(phase, target, "@tags", async () =>
{
await BrowseAsync(phase, target, factories.Enumerators, lib, afterEach: null, ct).ConfigureAwait(false);
await ReadTemplatesAsync(phase, target, factories.Templates, lib, afterEach: null, ct).ConfigureAwait(false);
var selection = Select(target, lib);
await ReadTagsAsync(phase, target, factories.Tags, selection, lib.Reads, afterEach: null, ct).ConfigureAwait(false);
// Volatility probe: read every tag again, paced, so a value that moves on its own (a live analog) is
// recognised as live rather than reported as a transport divergence when native reads it later.
await ReadTagsAsync(phase, target, factories.Tags, selection, lib.ProbeReads, afterEach: null, ct).ConfigureAwait(false);
return ((TransportObservations)lib, selection);
}, ct);
}
/// <summary>
/// Holds one libplctag handle on <paramref name="anchorTag"/> for the whole of <paramref name="body"/>, so every
/// libplctag operation in it shares ONE session and ONE Forward Open. A failure to create the anchor aborts
/// before <paramref name="body"/> runs; the anchor is disposed when <paramref name="body"/> ends, either way.
/// Shared by the differential (anchor <c>@tags</c>) and the latency comparison (anchor = its first tag).
/// </summary>
internal static async Task<T> WithLibplctagAnchorAsync<T>(
string phase, DifferentialTarget target, string anchorTag, Func<Task<T>> body, CancellationToken ct)
{
var factories = AbCipTransportFactories.For(AbCipTransport.Libplctag);
using var anchor = factories.Tags.Create(target.Params(anchorTag));
try
{
await anchor.InitializeAsync(ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
throw Abort(phase, "anchor session (@tags)", ex);
throw Abort(phase, $"anchor session ({anchorTag})", ex);
}
await BrowseAsync(phase, target, factories.Enumerators, lib, afterEach: null, ct).ConfigureAwait(false);
await ReadTemplatesAsync(phase, target, factories.Templates, lib, afterEach: null, ct).ConfigureAwait(false);
var selection = Select(target, lib);
await ReadTagsAsync(phase, target, factories.Tags, selection, lib, afterEach: null, ct).ConfigureAwait(false);
return (lib, selection);
return await body().ConfigureAwait(false);
}
/// <summary>The native phase alone; internal so the offline abort test can drive it against a fake endpoint.</summary>
internal static async Task<(TransportObservations Observations, int ForwardOpens)> RunNativePhaseAsync(
internal static async Task<(TransportObservations Observations, int ForwardOpens, NativeCloses ForwardCloses)> RunNativePhaseAsync(
DifferentialTarget target, IReadOnlyList<SelectedTag> selection, CancellationToken ct)
{
const string phase = "native";
var native = new TransportObservations();
var factories = AbCipTransportFactories.For(AbCipTransport.Native);
var (_, forwardOpens, forwardCloses) = await WithNativeAnchorAsync(phase, target, async checkOpens =>
{
await BrowseAsync(phase, target, factories.Enumerators, native, checkOpens, ct).ConfigureAwait(false);
await ReadTemplatesAsync(phase, target, factories.Templates, native, checkOpens, ct).ConfigureAwait(false);
await ReadTagsAsync(phase, target, factories.Tags, selection, native.Reads, checkOpens, ct).ConfigureAwait(false);
return true;
}).ConfigureAwait(false);
return (native, forwardOpens, forwardCloses);
}
/// <summary>
/// Runs <paramref name="body"/> with every native operation sharing ONE connection, held open by an anchor
/// lease: the process-wide Forward Open counter is reset, the open-attempt budget is set to exactly one
/// more attempt, and <paramref name="body"/> receives a check to call after each operation that aborts the
/// moment a second Forward Open reaches the wire. The anchor is released and the budget restored in
/// <c>finally</c>. Shared by the differential and the latency comparison.
/// </summary>
/// <returns>The body's result, the Forward Opens counted between the reset and the anchor release, and the
/// Forward Closes counted between the reset and the END of the anchor release (which awaits the close).</returns>
internal static async Task<(T Result, int ForwardOpens, NativeCloses ForwardCloses)> WithNativeAnchorAsync<T>(
string phase, DifferentialTarget target, Func<Action<string>, Task<T>> body)
{
var anchorParams = target.Params("@tags");
void CheckOpens(string operation)
@@ -164,28 +237,33 @@ internal static class DifferentialRun
}
Interlocked.Exchange(ref CipConnection.ForwardOpenCount, 0);
Interlocked.Exchange(ref CipConnection.ForwardClosesSent, 0);
Interlocked.Exchange(ref CipConnection.ForwardClosesAcked, 0);
Volatile.Write(ref CipConnection.OpenAttemptBudget, Volatile.Read(ref CipConnection.OpenAttempts) + 1);
T result;
int forwardOpens;
NativeCloses forwardCloses;
try
{
// Acquire does no I/O; the key and options carry no tag-specific field, so every lease shares this entry.
var anchor = DeviceSessionPool.Shared.Acquire(NativeAbCipDevice.Key(anchorParams), NativeAbCipDevice.Options(anchorParams));
try
{
await BrowseAsync(phase, target, factories.Enumerators, native, CheckOpens, ct).ConfigureAwait(false);
await ReadTemplatesAsync(phase, target, factories.Templates, native, CheckOpens, ct).ConfigureAwait(false);
await ReadTagsAsync(phase, target, factories.Tags, selection, native, CheckOpens, ct).ConfigureAwait(false);
result = await body(CheckOpens).ConfigureAwait(false);
}
finally
{
forwardOpens = Volatile.Read(ref CipConnection.ForwardOpenCount);
await anchor.DisposeAsync().ConfigureAwait(false);
forwardCloses = new NativeCloses(
Volatile.Read(ref CipConnection.ForwardClosesSent), Volatile.Read(ref CipConnection.ForwardClosesAcked));
}
}
finally
{
Volatile.Write(ref CipConnection.OpenAttemptBudget, -1);
}
return (native, forwardOpens);
return (result, forwardOpens, forwardCloses);
}
private static async Task BrowseAsync(
@@ -357,7 +435,7 @@ internal static class DifferentialRun
private static async Task ReadTagsAsync(
string phase, DifferentialTarget target, IAbCipTagFactory factory, IReadOnlyList<SelectedTag> selection,
TransportObservations into, Action<string>? afterEach, CancellationToken ct)
Dictionary<string, ReadObservation> into, Action<string>? afterEach, CancellationToken ct)
{
foreach (var tag in selection)
{
@@ -396,7 +474,8 @@ internal static class DifferentialRun
decodeError = Describe(ex); // recorded, and a NO verdict (review r1 M3)
}
}
into.Reads[tag.Name] = new ReadObservation(status, error, raw, decoded, decodeError);
into[tag.Name] = new ReadObservation(status, error, raw, decoded, decodeError,
(runtime as NativeAbCipTagRuntime)?.TypeCode);
}
finally
{
@@ -422,11 +501,11 @@ internal static class DifferentialRun
_ => true,
};
private static DifferentialAbortedException Abort(string phase, string operation, Exception ex) =>
internal static DifferentialAbortedException Abort(string phase, string operation, Exception ex) =>
new($"Differential ABORTED in the {phase} phase at {operation}: {Describe(ex)}. " +
"Stopped on the first connection-level failure; nothing was retried.", ex);
private static Task PaceAsync(DifferentialTarget target, CancellationToken ct) =>
internal static Task PaceAsync(DifferentialTarget target, CancellationToken ct) =>
target.PaceMs > 0 ? Task.Delay(target.PaceMs, ct) : Task.CompletedTask;
/// <summary>Resolves the endpoint once, bounded (an IP literal needs no lookup).</summary>
@@ -448,14 +527,16 @@ internal static class DifferentialRun
}
/// <summary>
/// libplctag closes its session on a background thread some time after the last handle is destroyed.
/// Wait until no TCP connection to the endpoint remains that was not already there before the run
/// (compared by local endpoint, not by count — review r1 M6), so the two transports are never
/// A transport closes its session asynchronously (libplctag on a background thread some time after the last
/// handle is destroyed). Wait until no TCP connection to the endpoint remains that was not already there
/// before the run (compared by local endpoint, not by count — review r1 M6), so two sessions are never
/// connected to the controller at the same time.
/// </summary>
/// <returns>The connection count observed when native work may start (null when unobservable).</returns>
private static async Task<int?> WaitForLibplctagSessionToCloseAsync(
DifferentialTarget target, IReadOnlySet<IPAddress> addresses, IReadOnlySet<IPEndPoint>? baseline, CancellationToken ct)
/// <param name="whose">Names the session waited for, in the abort message.</param>
/// <returns>The connection count observed when the next phase may start (null when unobservable).</returns>
internal static async Task<int?> WaitForSessionsToCloseAsync(
DifferentialTarget target, IReadOnlySet<IPAddress> addresses, IReadOnlySet<IPEndPoint>? baseline, string whose,
CancellationToken ct)
{
if (baseline is null)
{
@@ -471,8 +552,8 @@ internal static class DifferentialRun
if (added.Count == 0) return now.Count;
if (deadline.Elapsed > LibplctagDrainBudget)
throw new DifferentialAbortedException(
$"libplctag's session to {target.Host}:{target.Port} was still open {LibplctagDrainBudget.TotalSeconds:0}s after its " +
$"handles were disposed (local {string.Join(", ", added)}); refusing to start the native phase alongside it.");
$"{whose}'s session to {target.Host}:{target.Port} was still open {LibplctagDrainBudget.TotalSeconds:0}s after it was " +
$"released (local {string.Join(", ", added)}); refusing to start the next phase alongside it.");
await Task.Delay(TimeSpan.FromMilliseconds(250), ct).ConfigureAwait(false);
}
}
@@ -514,6 +595,65 @@ internal static class DifferentialRun
? StructuralComparisons.StructuralEqualityComparer.Equals(a, b)
: Equals(a, b);
/// <summary>
/// True when the libplctag volatility probe saw the value move by itself: both libplctag reads succeeded and
/// their bytes differ. A missing or failed probe is never live, so such a tag keeps the full value comparison.
/// </summary>
internal static bool IsLive(ReadObservation lib, ReadObservation? probe) =>
probe is not null && lib.Succeeded && probe.Succeeded && lib.Raw is not null && probe.Raw is not null
&& !lib.Raw.AsSpan().SequenceEqual(probe.Raw);
/// <summary>
/// The value check that survives for a LIVE tag (T21 review r1 I1). A scalar is one element, an array element-wise
/// over the decoded values:
/// <list type="bullet">
/// <item>an element EQUAL in both libplctag reads did not move, so native must read it equal too —
/// otherwise <c>**NO: stable element i**</c> (a stride, offset or byte-order defect lands here);</item>
/// <item>an element that moved is excused its value, but native must not read a NaN/∞ that neither
/// libplctag read saw — <c>**NO: non-finite element i**</c>.</item>
/// </list>
/// Null means the check passed. Shapes must already agree between libplctag and native.
/// </summary>
internal static string? LiveElementMismatch(object? lib, object? probe, object? nat)
{
var l = Elements(lib);
var p = Elements(probe);
var n = Elements(nat);
if (l.Length != p.Length) return "**NO: probe shape**"; // libplctag disagreed with itself: never excuse it
if (l.Length != n.Length) return "**NO: shape**";
for (var i = 0; i < l.Length; i++)
{
if (Equals(l[i], p[i]))
{
if (!Equals(l[i], n[i])) return $"**NO: stable element {i}**";
}
else if (!IsFinite(n[i]) && !Equals(n[i], l[i]) && !Equals(n[i], p[i]))
{
return $"**NO: non-finite element {i}**";
}
}
return null;
}
private static object?[] Elements(object? v) => v is Array a ? a.Cast<object?>().ToArray() : [v];
private static bool IsFinite(object? v) => v switch
{
float f => float.IsFinite(f),
double d => double.IsFinite(d),
_ => true,
};
/// <summary>The same decoded shape: both scalars of one CLR type, or arrays of one element type and length.</summary>
internal static bool SameShape(object? a, object? b) => (a, b) switch
{
(null, null) => true,
(Array x, Array y) => x.GetType() == y.GetType() && x.Length == y.Length,
(Array, _) or (_, Array) => false,
(not null, not null) => a.GetType() == b.GetType(),
_ => false,
};
/// <summary>Equal only when BOTH decoded: an undecodable shape is never parity (review r1 I3).</summary>
internal static bool ShapesEqual(AbCipUdtShape? a, AbCipUdtShape? b) =>
a is not null && b is not null
@@ -102,7 +102,8 @@ public sealed class DifferentialSafetyTests
[Fact]
public void Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping()
{
var saved = Snapshot(DifferentialTarget.EndpointVar, DifferentialTarget.MaxTagsVar, DifferentialTarget.MaxTemplatesVar, DifferentialTarget.PaceVar);
var saved = Snapshot(DifferentialTarget.EndpointVar, DifferentialTarget.MaxTagsVar, DifferentialTarget.MaxTemplatesVar, DifferentialTarget.PaceVar,
DifferentialTarget.LatencyTagsVar, DifferentialTarget.LatencyPassesVar, DifferentialTarget.LatencyPassGapVar);
try
{
Environment.SetEnvironmentVariable(DifferentialTarget.EndpointVar, "127.0.0.1:1");
@@ -115,10 +116,29 @@ public sealed class DifferentialSafetyTests
Environment.SetEnvironmentVariable(DifferentialTarget.MaxTemplatesVar, null);
Environment.SetEnvironmentVariable(DifferentialTarget.PaceVar, null);
var t = DifferentialTarget.FromEnvironment()!;
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyTagsVar, null);
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassesVar, null);
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassGapVar, null);
var t =DifferentialTarget.FromEnvironment()!;
t.MaxTags.ShouldBe(500);
t.MaxTemplates.ShouldBe(DifferentialTarget.DefaultMaxTemplates);
t.PaceMs.ShouldBe(DifferentialTarget.DefaultPaceMs);
t.LatencyTags.ShouldBe(100);
t.LatencyPasses.ShouldBe(30);
t.LatencyPassGapMs.ShouldBe(200);
// Latency knobs (Task 21): the same fail-fast rule, with tighter ceilings.
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyTagsVar, "201");
Should.Throw<InvalidOperationException>(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.LatencyTagsVar);
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyTagsVar, "200");
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassesVar, "51");
Should.Throw<InvalidOperationException>(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.LatencyPassesVar);
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassesVar, "50");
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassGapVar, "10001");
Should.Throw<InvalidOperationException>(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.LatencyPassGapVar);
Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassGapVar, "0");
var lat = DifferentialTarget.FromEnvironment()!;
(lat.LatencyTags, lat.LatencyPasses, lat.LatencyPassGapMs).ShouldBe((200, 50, 0));
}
finally
{
@@ -14,6 +14,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
/// <item><c>AB_DIFF_MAX_TEMPLATES</c> — cap on template reads per transport, default 50, at most 500.</item>
/// <item><c>AB_DIFF_PACE_MS</c> — delay before every harness-issued request, default 20 ms; 0 for ab_server.</item>
/// <item><c>AB_DIFF_COUNT_OPENS</c> — <c>1</c> ⇒ assert exactly one native Forward Open over the run.</item>
/// <item><c>AB_LAT_TAGS</c> — latency comparison: tags read per pass, default 100, at most 200 (higher fails fast).</item>
/// <item><c>AB_LAT_PASSES</c> — latency comparison: passes per transport, default 30, at most 50.</item>
/// <item><c>AB_LAT_PASS_GAP_MS</c> — latency comparison: pause between passes, default 200 ms, at most 10000.</item>
/// </list>
/// </summary>
internal sealed record DifferentialTarget(
@@ -25,7 +28,10 @@ internal sealed record DifferentialTarget(
int MaxTags,
bool CountOpens,
int MaxTemplates = DifferentialTarget.DefaultMaxTemplates,
int PaceMs = DifferentialTarget.DefaultPaceMs)
int PaceMs = DifferentialTarget.DefaultPaceMs,
int LatencyTags = DifferentialTarget.DefaultLatencyTags,
int LatencyPasses = DifferentialTarget.DefaultLatencyPasses,
int LatencyPassGapMs = DifferentialTarget.DefaultLatencyPassGapMs)
{
public const string EndpointVar = "AB_DIFF_ENDPOINT";
public const string PathVar = "AB_DIFF_PATH";
@@ -35,6 +41,16 @@ internal sealed record DifferentialTarget(
public const string CountOpensVar = "AB_DIFF_COUNT_OPENS";
public const string MaxTemplatesVar = "AB_DIFF_MAX_TEMPLATES";
public const string PaceVar = "AB_DIFF_PACE_MS";
public const string LatencyTagsVar = "AB_LAT_TAGS";
public const string LatencyPassesVar = "AB_LAT_PASSES";
public const string LatencyPassGapVar = "AB_LAT_PASS_GAP_MS";
/// <summary>Latency comparison ceilings: a production press is the target, and each pass is one burst.</summary>
public const int LatencyTagsCeiling = 200;
public const int LatencyPassesCeiling = 50;
public const int DefaultLatencyTags = 100;
public const int DefaultLatencyPasses = 30;
public const int DefaultLatencyPassGapMs = 200;
/// <summary>Hard ceiling on <c>AB_DIFF_MAX_TAGS</c> / <c>AB_DIFF_MAX_TEMPLATES</c>: a production press is the target.</summary>
public const int Ceiling = 500;
@@ -86,8 +102,13 @@ internal sealed record DifferentialTarget(
if (tags is { Count: > Ceiling })
throw new InvalidOperationException($"{TagsVar} names {tags.Count} tags; the ceiling is {Ceiling}.");
var latencyTags = BoundedInt(LatencyTagsVar, DefaultLatencyTags, 1, LatencyTagsCeiling);
var latencyPasses = BoundedInt(LatencyPassesVar, DefaultLatencyPasses, 1, LatencyPassesCeiling);
var latencyGap = BoundedInt(LatencyPassGapVar, DefaultLatencyPassGapMs, 0, 10_000);
var countOpens = Environment.GetEnvironmentVariable(CountOpensVar) == "1";
return new DifferentialTarget(host, port, path, family, tags, max, countOpens, maxTemplates, pace);
return new DifferentialTarget(host, port, path, family, tags, max, countOpens, maxTemplates, pace,
latencyTags, latencyPasses, latencyGap);
}
/// <summary>Reads an integer knob; out of range FAILS rather than clamping, so a typo never silently widens a press run.</summary>
@@ -0,0 +1,134 @@
using System.Globalization;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
/// <summary>
/// Task 21 latency comparison: a fixed set of atomic tags polled through libplctag, then through the native
/// transport, each pass issuing every read at once. Pass criterion: native p95 ≤ libplctag p95 × 1.10.
/// <para>Gated by the same variables as the differential (<c>AB_DIFF_ENDPOINT</c> absent ⇒ skip), in the same
/// serialized collection, and read-only under the same source/IL guard
/// (<see cref="TransportDifferentialTests.The_harness_is_read_only_by_construction"/>). It runs AFTER the
/// differential's one run, reusing its browse for tag selection, so the target sees one listing per transport,
/// not two. Knobs: <c>AB_LAT_TAGS</c>, <c>AB_LAT_PASSES</c>, <c>AB_LAT_PASS_GAP_MS</c> (see
/// <see cref="DifferentialTarget"/>).</para>
/// </summary>
[Collection(DifferentialCollection.Name)]
[Trait("Category", "LiveIntegration")]
public sealed class PollLatencyComparisonTests(DifferentialRunFixture fixture)
{
private const double Tolerance = 1.10;
private static readonly TimeSpan RunBudget = TimeSpan.FromMinutes(10);
[Fact]
public async Task Native_poll_p95_is_within_ten_percent_of_libplctag()
{
var target = DifferentialTarget.FromEnvironment();
if (target is null) Assert.Skip(DifferentialTarget.SkipReason);
var differential = await fixture.GetAsync();
using var cts = new CancellationTokenSource(RunBudget);
var r = await PollLatencyRun.ExecuteAsync(target, differential, cts.Token);
Log($"## Latency — {r.Target}");
Log($"Tags per pass: {r.Tags.Count} ({r.DistinctTags} distinct" +
(r.Repeated ? $", REPEATED cyclically to reach {DifferentialTarget.LatencyTagsVar}={r.Target.LatencyTags}" : "") +
$"); passes per transport: {r.Target.LatencyPasses}; gap between passes: {r.Target.LatencyPassGapMs} ms.");
Log("Types: " + string.Join(", ", r.Tags.GroupBy(t => t.Type).Select(g => $"{g.Key} {g.Count()}")));
if (r.Dropped.Count > 0) Log($"Dropped (libplctag refused at setup, removed from both sets): {string.Join(", ", r.Dropped)}");
Log($"Native requests spent on setup (one read per tag): {r.NativeSetupRequests}; native Forward Opens: {r.NativeForwardOpens}; " +
$"native Forward Closes sent/acked: {r.NativeForwardCloses.Sent}/{r.NativeForwardCloses.Acked}; " +
$"connections after the native phase: {r.ConnectionsAfterNative?.ToString(CultureInfo.InvariantCulture) ?? "unobservable"}.");
Log($"libplctag orphans destroyed so far: {LibplctagOrphanRelease.Released}; rooted (unreleasable): {LibplctagOrphanRelease.RootedCount}.");
Log("");
var libP50 = PollLatencyRun.Percentile(r.Libplctag.Select(p => p.Wall), 50);
var libP95 = PollLatencyRun.Percentile(r.Libplctag.Select(p => p.Wall), 95);
var natP50 = PollLatencyRun.Percentile(r.Native.Select(p => p.Wall), 50);
var natP95 = PollLatencyRun.Percentile(r.Native.Select(p => p.Wall), 95);
var limit = libP95 * Tolerance;
Log("| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |");
Log("|---|---|---|---|---|---|---|---|");
Log(Row("libplctag", r.Libplctag, libP50, libP95));
Log(Row("native", r.Native, natP50, natP95));
Log("");
Log($"Criterion: native p95 {Ms(natP95)} ms ≤ libplctag p95 {Ms(libP95)} ms × {Tolerance:0.00} = {Ms(limit)} ms → " +
(natP95 <= limit ? "PASS" : "**FAIL**"));
Log("");
// Attribution (review r1 M8): GC collections during each pass (gen0/gen1/gen2) on both sides, and the
// round trip of each native CIP exchange — a slow pass with fast exchanges is a client-side stall.
Log("| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |");
Log("|---|---|---|---|---|---|---|");
for (var i = 0; i < Math.Max(r.Libplctag.Count, r.Native.Count); i++)
{
var lib = i < r.Libplctag.Count ? r.Libplctag[i] : null;
var nat = i < r.Native.Count ? r.Native[i] : null;
Log($"| {i + 1} | {(lib is null ? "-" : Ms(lib.Wall.TotalMilliseconds))} | {lib?.Gc.ToString() ?? "-"} | " +
$"{(nat is null ? "-" : Ms(nat.Wall.TotalMilliseconds))} | {nat?.Gc.ToString() ?? "-"} | " +
$"{nat?.Requests?.ToString(CultureInfo.InvariantCulture) ?? "-"} | {Rtt(nat?.Rtt)} |");
}
foreach (var p in r.Problems) Log($"- PROBLEM: {p}");
r.Problems.ShouldBeEmpty();
r.Libplctag.Count.ShouldBe(r.Target.LatencyPasses);
r.Native.Count.ShouldBe(r.Target.LatencyPasses);
r.Libplctag.Sum(p => p.TagFailures).ShouldBe(0, "every timed libplctag read must succeed");
r.Native.Sum(p => p.TagFailures).ShouldBe(0, "every timed native read must succeed");
if (r.Target.CountOpens)
{
r.NativeForwardOpens.ShouldBe(1);
r.NativeForwardCloses.Sent.ShouldBe(1, "the native phase's one connection must be closed with exactly one Forward Close");
}
if (r.ConnectionsAfterNative is { } after && differential.ConnectionsBaseline is { } baseline)
after.ShouldBeLessThanOrEqualTo(baseline, "the native session must be gone when the run reports");
// Review r1 M4: the latency setup's refused libplctag tags are orphan candidates too — checked after them.
LibplctagOrphanRelease.RootedCount.ShouldBe(0);
natP95.ShouldBeLessThanOrEqualTo(limit, $"native p95 must be ≤ libplctag p95 × {Tolerance:0.00}");
}
/// <summary>Offline: the tag pick takes atomic scalars only, and repeats a short set deliberately and visibly.</summary>
[Fact]
public void Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly()
{
var browsed = new TransportObservations();
browsed.Browse.Add(new AbCipDiscoveredTag("D", null, AbCipDataType.DInt, false));
browsed.Browse.Add(new AbCipDiscoveredTag("R", null, AbCipDataType.Real, false));
browsed.Browse.Add(new AbCipDiscoveredTag("S", null, AbCipDataType.String, false));
browsed.Browse.Add(new AbCipDiscoveredTag("T", null, AbCipDataType.Dt, false));
browsed.Browse.Add(new AbCipDiscoveredTag("A", null, AbCipDataType.DInt, false, ElementCount: 4, IsArray: true));
browsed.Browse.Add(new AbCipDiscoveredTag("Sys", null, AbCipDataType.DInt, false, IsSystemTag: true));
var target = new DifferentialTarget("127.0.0.1", 1, "1,0", AbCipPlcFamily.CompactLogix, null, 200, false, LatencyTags: 5);
var (tags, distinct, repeated) = PollLatencyRun.PickTags(target, browsed);
distinct.ShouldBe(2);
repeated.ShouldBeTrue();
tags.Select(t => t.Name).ShouldBe(["D", "R", "D", "R", "D"]);
var (one, oneDistinct, oneRepeated) = PollLatencyRun.PickTags(target with { LatencyTags = 1 }, browsed);
one.ShouldHaveSingleItem().Name.ShouldBe("D");
oneDistinct.ShouldBe(1);
oneRepeated.ShouldBeFalse();
PollLatencyRun.Percentile([TimeSpan.FromMilliseconds(3), TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(2)], 50)
.ShouldBe(2);
PollLatencyRun.Percentile(Enumerable.Range(1, 20).Select(i => TimeSpan.FromMilliseconds(i)), 95).ShouldBe(19);
}
private static string Row(string name, IReadOnlyList<LatencyPass> passes, double p50, double p95)
{
var walls = passes.Select(p => p.Wall.TotalMilliseconds).ToList();
var requests = passes.Where(p => p.Requests is not null).Select(p => p.Requests!.Value).Order().ToList();
var medianRequests = requests.Count == 0 ? "n/a" : requests[requests.Count / 2].ToString(CultureInfo.InvariantCulture);
return $"| {name} | {passes.Count} | {Ms(p50)} | {Ms(p95)} | {Ms(walls.Min())} | {Ms(walls.Max())} | {medianRequests} | " +
$"{passes.Sum(p => p.TagFailures)} |";
}
private static string Rtt(ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip.CipConnection.ExchangeStats? s) =>
s is not { Count: > 0 } x ? "-" : $"{Ms(x.MinMs)}/{Ms(x.MeanMs)}/{Ms(x.MaxMs)}";
private static string Ms(double ms) => ms.ToString("0.0", CultureInfo.InvariantCulture);
private static void Log(string line) => TestContext.Current.TestOutputHelper?.WriteLine(line);
}
@@ -0,0 +1,268 @@
using System.Diagnostics;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
/// <summary>One timed burst: every tag read once, concurrently.</summary>
/// <param name="Requests">Native only: <see cref="CipConnection.RequestsSent"/> delta over the pass (keep-alives included).</param>
/// <param name="TagFailures">Reads that ended with a tag-level status (a connection-level one aborts instead).</param>
/// <param name="Gc">Collections that ran during the pass, per generation (T21 review r1 M8: a client-side stall shows here).</param>
/// <param name="Rtt">Native only: round trip of each CIP exchange in the pass, from <see cref="CipConnection"/> (a slow
/// controller shows here; a slow pass with fast exchanges points at the client).</param>
internal sealed record LatencyPass(
int Index, TimeSpan Wall, int? Requests, int TagFailures, GcDelta Gc = default, CipConnection.ExchangeStats? Rtt = null);
/// <summary>GC collections per generation over a window (gen0 counts include gen1/gen2 collections, as the runtime reports).</summary>
internal readonly record struct GcDelta(int Gen0, int Gen1, int Gen2)
{
public static GcDelta Now() => new(GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2));
public static GcDelta operator -(GcDelta a, GcDelta b) => new(a.Gen0 - b.Gen0, a.Gen1 - b.Gen1, a.Gen2 - b.Gen2);
public override string ToString() => $"{Gen0}/{Gen1}/{Gen2}";
}
/// <summary>The outcome of the latency comparison.</summary>
internal sealed class LatencyResult
{
public required DifferentialTarget Target { get; init; }
/// <summary>The fixed set read every pass, in order (a name appears more than once only when <see cref="Repeated"/>).</summary>
public required IReadOnlyList<SelectedTag> Tags { get; init; }
public required int DistinctTags { get; init; }
public required bool Repeated { get; init; }
/// <summary>Names libplctag could not read at setup (tag-level status): dropped from BOTH transports' sets.</summary>
public required IReadOnlyList<string> Dropped { get; init; }
public required IReadOnlyList<LatencyPass> Libplctag { get; init; }
public required IReadOnlyList<LatencyPass> Native { get; init; }
public required int NativeForwardOpens { get; init; }
/// <summary>Forward Closes the native phase sent / got a reply to, its anchor release included (review r1 M9).</summary>
public required NativeCloses NativeForwardCloses { get; init; }
/// <summary>TCP connections to the endpoint once the native phase's session was released and drained.</summary>
public required int? ConnectionsAfterNative { get; init; }
/// <summary>Native requests spent creating the runtimes (one read each, before any timed pass).</summary>
public required int NativeSetupRequests { get; init; }
public required List<string> Problems { get; init; }
}
/// <summary>
/// Task 21's poll-latency comparison, read-only and built on the differential's machinery: the same
/// endpoint parsing, the same anchors (one libplctag handle, one native lease), the same open-attempt budget,
/// the same connection barrier and the same stop-on-first-connection-failure rule.
/// <para>Order: the differential's run (its browse supplies the tags — no second listing), a barrier until its
/// native session is gone, every libplctag pass with every handle then disposed, a barrier until libplctag's
/// session is gone, then every native pass under one anchor lease. The two transports never overlap.</para>
/// <para>Per transport: create and read every tag once (untimed, paced by <c>AB_DIFF_PACE_MS</c>), then
/// <c>AB_LAT_PASSES</c> timed passes, each issuing all <c>AB_LAT_TAGS</c> reads at once (<c>Task.WhenAll</c>)
/// so the native shared scheduler can pack them into Multiple Service Packets as libplctag packs internally,
/// with <c>AB_LAT_PASS_GAP_MS</c> of quiet between passes.</para>
/// </summary>
internal static class PollLatencyRun
{
private static readonly HashSet<AbCipDataType> Atomic =
[
AbCipDataType.Bool, AbCipDataType.SInt, AbCipDataType.Int, AbCipDataType.DInt, AbCipDataType.LInt,
AbCipDataType.USInt, AbCipDataType.UInt, AbCipDataType.UDInt, AbCipDataType.ULInt,
AbCipDataType.Real, AbCipDataType.LReal,
];
/// <summary>
/// The fixed tag set: atomic, non-array scalars, from the differential's libplctag browse — or, when
/// <c>AB_DIFF_TAGS</c> is set, those of its names whose browsed type is an atomic scalar. When fewer
/// distinct tags exist than <c>AB_LAT_TAGS</c>, the set is REPEATED cyclically up to the count (each
/// repeat is its own runtime/handle on the same name) and reported as such — never silently.
/// </summary>
internal static (IReadOnlyList<SelectedTag> Tags, int Distinct, bool Repeated) PickTags(
DifferentialTarget target, TransportObservations browsed)
{
var candidates = DifferentialRun.Select(target with { MaxTags = DifferentialTarget.Ceiling }, browsed)
.Where(t => !t.IsArray && t.Type is { } type && Atomic.Contains(type))
.DistinctBy(t => t.Name)
.Take(target.LatencyTags)
.ToList();
if (candidates.Count == 0 || candidates.Count >= target.LatencyTags) return (candidates, candidates.Count, false);
var repeated = Enumerable.Range(0, target.LatencyTags).Select(i => candidates[i % candidates.Count]).ToList();
return (repeated, candidates.Count, true);
}
public static async Task<LatencyResult> ExecuteAsync(DifferentialTarget target, DifferentialResult differential, CancellationToken ct)
{
var (picked, distinct, repeated) = PickTags(target, differential.Libplctag);
if (picked.Count == 0)
throw new InvalidOperationException("The browse offered no atomic scalar tags to time; nothing was read.");
// The differential's native session must be gone before libplctag connects.
await DifferentialRun.WaitForSessionsToCloseAsync(
target, differential.Addresses, differential.BaselineEndpoints, "the differential's native", ct).ConfigureAwait(false);
// ---- libplctag: setup, passes, every handle disposed (anchor last). ----
var dropped = new List<string>();
var libFactory = AbCipTransportFactories.For(AbCipTransport.Libplctag).Tags;
// Anchor on a tag the differential already read Ok through libplctag, so the anchor is never the refusal.
var anchorTag = picked.FirstOrDefault(t => differential.Libplctag.Reads.TryGetValue(t.Name, out var o) && o.Succeeded)
?? picked[0];
var (libPasses, tags) = await DifferentialRun.WithLibplctagAnchorAsync("latency libplctag", target, anchorTag.Name, async () =>
{
var runtimes = new List<(SelectedTag Tag, IAbCipTagRuntime Runtime)>();
try
{
foreach (var tag in picked)
{
var rt = await CreateAsync("latency libplctag", target, libFactory, tag, ct).ConfigureAwait(false);
if (rt is null) { dropped.Add(tag.Name); continue; }
runtimes.Add((tag, rt));
}
var passes = await RunPassesAsync("latency libplctag", target, runtimes, countRequests: false, afterEach: null, ct)
.ConfigureAwait(false);
return (passes, (IReadOnlyList<SelectedTag>)runtimes.Select(r => r.Tag).ToList());
}
finally
{
foreach (var (_, rt) in runtimes) rt.Dispose();
}
}, ct).ConfigureAwait(false);
// ---- Barrier: libplctag's session must be gone before the native side connects. ----
await DifferentialRun.WaitForSessionsToCloseAsync(
target, differential.Addresses, differential.BaselineEndpoints, "libplctag", ct).ConfigureAwait(false);
// ---- native: the same (post-drop) set, under one anchor lease. ----
var problems = new List<string>();
var nativeFactory = AbCipTransportFactories.For(AbCipTransport.Native).Tags;
var ((nativePasses, setupRequests), opens, closes) = await DifferentialRun.WithNativeAnchorAsync("latency native", target, async checkOpens =>
{
var runtimes = new List<(SelectedTag Tag, IAbCipTagRuntime Runtime)>();
try
{
var before = Volatile.Read(ref CipConnection.RequestsSent);
foreach (var tag in tags)
{
var rt = await CreateAsync("latency native", target, nativeFactory, tag, ct).ConfigureAwait(false);
checkOpens($"setup of '{tag.Name}'");
if (rt is null) { problems.Add($"native could not read '{tag.Name}', which libplctag read"); continue; }
runtimes.Add((tag, rt));
}
var setup = Volatile.Read(ref CipConnection.RequestsSent) - before;
var passes = await RunPassesAsync("latency native", target, runtimes, countRequests: true, checkOpens, ct)
.ConfigureAwait(false);
return (passes, setup);
}
finally
{
foreach (var (_, rt) in runtimes) rt.Dispose();
}
}).ConfigureAwait(false);
// ---- Barrier: the native session must be gone before the run reports (review r1 M9). ----
var afterNative = await DifferentialRun.WaitForSessionsToCloseAsync(
target, differential.Addresses, differential.BaselineEndpoints, "latency native", ct).ConfigureAwait(false);
return new LatencyResult
{
Target = target,
Tags = tags,
DistinctTags = distinct,
Repeated = repeated,
Dropped = dropped,
Libplctag = libPasses,
Native = nativePasses,
NativeForwardOpens = opens,
NativeForwardCloses = closes,
ConnectionsAfterNative = afterNative,
NativeSetupRequests = setupRequests,
Problems = problems,
};
}
/// <summary>Create + first read of one tag. Null on a tag-level refusal; a connection-level failure aborts.</summary>
private static async Task<IAbCipTagRuntime?> CreateAsync(
string phase, DifferentialTarget target, IAbCipTagFactory factory, SelectedTag tag, CancellationToken ct)
{
var rt = factory.Create(target.Params(tag.Name, tag.ElementCount, tag.IsArray, tag.Type));
try
{
await DifferentialRun.PaceAsync(target, ct).ConfigureAwait(false);
await rt.InitializeAsync(ct).ConfigureAwait(false);
if (rt.GetStatus() == (int)AbStatus.Ok) return rt;
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
if (DifferentialRun.IsConnectionLevel(ex))
{
rt.Dispose();
throw DifferentialRun.Abort(phase, $"setup of '{tag.Name}'", ex);
}
}
catch
{
rt.Dispose();
throw;
}
rt.Dispose();
return null;
}
private static async Task<List<LatencyPass>> RunPassesAsync(
string phase, DifferentialTarget target, List<(SelectedTag Tag, IAbCipTagRuntime Runtime)> runtimes,
bool countRequests, Action<string>? afterEach, CancellationToken ct)
{
var passes = new List<LatencyPass>(target.LatencyPasses);
for (var p = 1; p <= target.LatencyPasses; p++)
{
if (p > 1 && target.LatencyPassGapMs > 0) await Task.Delay(target.LatencyPassGapMs, ct).ConfigureAwait(false);
var requestsBefore = Volatile.Read(ref CipConnection.RequestsSent);
CipConnection.ResetExchangeStats();
var gcBefore = GcDelta.Now();
var clock = Stopwatch.StartNew();
var outcomes = await Task.WhenAll(runtimes.Select(r => ReadOnceAsync(r.Runtime, ct))).ConfigureAwait(false);
clock.Stop();
var gc = GcDelta.Now() - gcBefore;
var rtt = countRequests ? CipConnection.ReadExchangeStats() : (CipConnection.ExchangeStats?)null;
var requests = Volatile.Read(ref CipConnection.RequestsSent) - requestsBefore;
var failures = 0;
for (var i = 0; i < outcomes.Length; i++)
{
if (outcomes[i] is { } ex)
{
if (DifferentialRun.IsConnectionLevel(ex))
throw DifferentialRun.Abort(phase, $"pass {p}, tag '{runtimes[i].Tag.Name}'", ex);
failures++;
}
else if (runtimes[i].Runtime.GetStatus() != (int)AbStatus.Ok)
{
failures++;
}
}
afterEach?.Invoke($"pass {p}");
passes.Add(new LatencyPass(p, clock.Elapsed, countRequests ? requests : null, failures, gc, rtt));
}
return passes;
}
private static async Task<Exception?> ReadOnceAsync(IAbCipTagRuntime runtime, CancellationToken ct)
{
try
{
await runtime.ReadAsync(ct).ConfigureAwait(false);
return null;
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
return ex;
}
}
/// <summary>Nearest-rank percentile (p in (0,100]) of the pass wall times, in milliseconds.</summary>
internal static double Percentile(IEnumerable<TimeSpan> walls, double p)
{
var sorted = walls.Select(w => w.TotalMilliseconds).Order().ToList();
if (sorted.Count == 0) throw new InvalidOperationException("No passes to take a percentile of.");
var rank = (int)Math.Ceiling(p / 100.0 * sorted.Count);
return sorted[Math.Clamp(rank, 1, sorted.Count) - 1];
}
}
@@ -95,25 +95,37 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
Log($"## Reads — {r.Selection.Count} tag(s), {r.Target}");
Log("Selection by origin: " + string.Join(", ",
r.Selection.GroupBy(t => t.Origin).Select(g => $"{g.Key} {g.Count()}")));
Log("| Tag | Type | libplctag status | native status | Equal? |");
Log("|---|---|---|---|---|");
Log("| Tag | Type | libplctag status | native status | libplctag probe | Equal? |");
Log("|---|---|---|---|---|---|");
var unexpected = new List<string>();
var expected = new List<string>();
var live = new List<string>();
foreach (var tag in r.Selection)
{
var lib = r.Libplctag.Reads[tag.Name];
var nat = r.Native.Reads[tag.Name];
var verdict = Compare(tag, lib, nat);
if (verdict.StartsWith("expected", StringComparison.Ordinal)) expected.Add($"{tag.Name}: {verdict}");
else if (verdict != "yes") unexpected.Add($"{tag.Name}: {verdict} (lib {Detail(lib)}; native {Detail(nat)})");
var probe = r.Libplctag.ProbeReads.GetValueOrDefault(tag.Name);
var verdict = Compare(tag, lib, nat, probe);
switch (Classify(verdict))
{
case Verdict.Expected: expected.Add($"{tag.Name}: {verdict}"); break;
case Verdict.Live: live.Add($"{tag.Name} (lib {Detail(lib, full: true)}; lib probe {Detail(probe!, full: true)}; native {Detail(nat, full: true)})"); break;
case Verdict.Unexpected when DifferentialRun.IsLive(lib, probe):
unexpected.Add($"{tag.Name}: {verdict} (live; lib {Detail(lib, full: true)}; lib probe {Detail(probe!, full: true)}; native {Detail(nat, full: true)})");
break;
case Verdict.Unexpected: unexpected.Add($"{tag.Name}: {verdict} (lib {Detail(lib)}; native {Detail(nat)})"); break;
}
Log($"| `{tag.Name}` | {tag.Type?.ToString() ?? "?"}{(tag.IsArray ? $"[{tag.ElementCount}]" : "")} | " +
$"{StatusName(lib.Status)} | {StatusName(nat.Status)} | {verdict} |");
$"{StatusName(lib.Status)} | {StatusName(nat.Status)} | {ProbeColumn(lib, probe)} | {verdict} |");
}
Log("");
Log($"Unexpected divergences: {unexpected.Count}; expected divergences: {expected.Count}.");
Log($"Unexpected divergences: {unexpected.Count}; expected divergences: {expected.Count}; " +
$"live tags (value moved between the two libplctag reads; compared by status, length, type code, shape and every " +
$"element that held still): {live.Count} of {r.Selection.Count}.");
foreach (var e in expected) Log($"- expected: {e}");
foreach (var l in live) Log($"- live: {l}");
foreach (var u in unexpected) Log($"- UNEXPECTED: {u}");
var coverage = DifferentialRun.SelectionCoverageProblems(r.Target, r.Libplctag, r.Selection);
@@ -138,6 +150,21 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
r.ConnectionsBeforeNative.Value.ShouldBeLessThanOrEqualTo(r.ConnectionsBaseline.Value);
}
/// <summary>
/// T21 press pass 1 ended in "A callback was made on a garbage collected delegate": libplctag tags whose
/// initialisation failed (NotFound tags, refused latency setups) were left native-side with a live callback.
/// Every libplctag tag this harness creates goes through the driver's adapters, which now destroy such a tag
/// (<see cref="LibplctagOrphanRelease"/>); this pins that none had to fall back to being rooted instead.
/// </summary>
[Fact]
public async Task No_failed_libplctag_tag_is_left_with_a_live_native_callback()
{
await RunOrSkipAsync();
Log($"libplctag orphans destroyed so far: {LibplctagOrphanRelease.Released}; rooted (unreleasable): {LibplctagOrphanRelease.RootedCount}.");
LibplctagOrphanRelease.CanReadHandles.ShouldBeTrue();
LibplctagOrphanRelease.RootedCount.ShouldBe(0);
}
[Fact]
public async Task The_native_side_makes_exactly_one_forward_open_over_the_whole_run()
{
@@ -149,6 +176,25 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
r.NativeForwardOpens.ShouldBe(1);
}
/// <summary>
/// T21 review r1 M9: the native session is closed properly — exactly one Forward Close on the wire (a skipped
/// close, e.g. on a socket already dropped, would otherwise be visible only in a capture), and no connection to
/// the target left once the anchor is released.
/// </summary>
[Fact]
public async Task The_native_side_closes_its_one_connection_with_exactly_one_forward_close()
{
var r = await RunOrSkipAsync();
Log($"Native Forward Closes sent/acked: {r.NativeForwardCloses.Sent}/{r.NativeForwardCloses.Acked}; " +
$"connections after the native phase: {r.ConnectionsAfterNative?.ToString(CultureInfo.InvariantCulture) ?? "unobservable"}.");
if (r.ConnectionsAfterNative is { } after && r.ConnectionsBaseline is { } baseline)
after.ShouldBeLessThanOrEqualTo(baseline);
if (!r.Target.CountOpens)
Assert.Skip($"{DifferentialTarget.CountOpensVar} is not 1 — the process-wide Forward Close counter is only " +
"trusted when the differential runs alone.");
r.NativeForwardCloses.Sent.ShouldBe(1);
}
/// <summary>
/// Offline guard — deliberately NOT gated on the endpoint, so it runs in every build. Fails if any
/// source file of this harness names a write-capable member, and, independently of the source text,
@@ -169,6 +215,10 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
var dir = Path.GetDirectoryName(ThisFile())!;
var sources = Directory.GetFiles(dir, "*.cs");
sources.ShouldContain(f => Path.GetFileName(f) == nameof(TransportDifferentialTests) + ".cs");
// Task 21's latency comparison and its run helper are part of the harness the press sees: pinned in scope,
// so moving either out of this folder (or namespace) fails the guard instead of silently escaping it.
sources.ShouldContain(f => Path.GetFileName(f) == nameof(PollLatencyComparisonTests) + ".cs");
sources.ShouldContain(f => Path.GetFileName(f) == nameof(PollLatencyRun) + ".cs");
var textHits = (from f in sources
let text = File.ReadAllText(f)
from token in forbidden
@@ -179,6 +229,8 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
var harnessTypes = typeof(TransportDifferentialTests).Assembly.GetTypes()
.Where(t => t.Namespace == typeof(TransportDifferentialTests).Namespace).ToList();
harnessTypes.ShouldContain(typeof(TransportDifferentialTests));
harnessTypes.ShouldContain(typeof(PollLatencyComparisonTests));
harnessTypes.ShouldContain(typeof(PollLatencyRun));
var callHits = (from t in harnessTypes
from m in MethodsOf(t)
from callee in CalleesOf(m)
@@ -217,13 +269,89 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
Compare(array, arr, arr with { Decoded = new[] { float.NaN, 2f } }).ShouldBe("**NO: value**");
}
/// <summary>
/// Offline pin of the live-drift handling (T21 press pass 1): a value that moved between the two libplctag
/// reads is compared structurally, while a value libplctag read stably and native read differently is still
/// an unexpected divergence. Removing the live handling turns the first assertion into "**NO: raw**".
/// </summary>
[Fact]
public void A_live_value_is_compared_structurally_and_a_stable_one_by_value()
{
var real = new SelectedTag("WW_M4ActualTemp", AbCipDataType.Real, 1, false, "scalar");
ReadObservation Obs(float v) => new((int)AbStatus.Ok, null, BitConverter.GetBytes(v), v);
var lib = Obs(384.88684f);
var probe = Obs(384.95f);
var nat = Obs(385.13782f) with { TypeCode = 0xCA };
// Live: the libplctag value moved by itself, so a different native value is not a divergence.
DifferentialRun.IsLive(lib, probe).ShouldBeTrue();
Compare(real, lib, nat, probe).ShouldBe("live");
Classify(Compare(real, lib, nat, probe)).ShouldBe(Verdict.Live);
// ...but everything structural still has to match.
Compare(real, lib, nat with { Status = (int)AbStatus.NotFound, Error = "NotFound" }, probe).ShouldBe("**NO: status**");
Compare(real, lib, nat with { Raw = new byte[8] }, probe).ShouldBe("**NO: raw length**");
Compare(real, lib, nat with { Raw = null }, probe).ShouldBe("**NO: raw length**");
Compare(real, lib with { TypeCode = 0xC4 }, nat, probe).ShouldBe("**NO: type code**");
Compare(real, lib, nat with { Decoded = 385 }, probe).ShouldBe("**NO: shape**");
Compare(real, lib, nat with { Decoded = new[] { 385f } }, probe).ShouldBe("**NO: shape**");
Compare(real, lib, nat with { DecodeError = "boom" }, probe).ShouldBe("**NO: decode**");
Classify("**NO: raw length**").ShouldBe(Verdict.Unexpected);
var array = new SelectedTag("A", AbCipDataType.Real, 2, true, "array");
var arrLib = new ReadObservation((int)AbStatus.Ok, null, new byte[8], new[] { 1f, 2f });
var arrProbe = arrLib with { Raw = [1, 0, 0, 0, 0, 0, 0, 0], Decoded = new[] { 1.1f, 2f } };
// Element 0 moved (1 → 1.1), element 1 held at 2: native may read anything finite for 0, but must read 2 for 1.
Compare(array, arrLib, arrLib with { Raw = [2, 0, 0, 0, 0, 0, 0, 0], Decoded = new[] { 3f, 2f } }, arrProbe).ShouldBe("live");
Compare(array, arrLib, arrLib with { Raw = [2, 0, 0, 0, 0, 0, 0, 0], Decoded = new[] { 3f, 4f } }, arrProbe)
.ShouldBe("**NO: stable element 1**");
Compare(array, arrLib, arrLib with { Decoded = new[] { 1f, 2f, 3f } }, arrProbe).ShouldBe("**NO: shape**");
// Review r1 I1, the pass-1 shape: Real[5] where element 0 (400000) holds still and 1..4 move. A native defect
// that shifts the stable element must FAIL even though the tag is live.
var fout = new SelectedTag("Program:EM01_PowerUnit.__fout9FB1561FA00D73F5", AbCipDataType.Real, 5, true, "udt member");
ReadObservation Arr(params float[] v) => new((int)AbStatus.Ok, null, v.SelectMany(BitConverter.GetBytes).ToArray(), v);
var foutLib = Arr(400000f, 4.2464733f, 5.2464733f, -6.7535267f, 4.2464733f);
var foutProbe = Arr(400000f, 4.2461f, 5.2461f, -6.7539f, 4.2461f);
Compare(fout, foutLib, Arr(400000f, 4.2457356f, 5.2457356f, -6.7542644f, 4.2457356f), foutProbe).ShouldBe("live");
var shifted = Compare(fout, foutLib, Arr(4.2457356f, 5.2457356f, -6.7542644f, 4.2457356f, 400000f), foutProbe);
shifted.ShouldBe("**NO: stable element 0**");
Classify(shifted).ShouldBe(Verdict.Unexpected);
Compare(fout, foutLib, Arr(400000f, float.NaN, 5.2457356f, -6.7542644f, 4.2457356f), foutProbe)
.ShouldBe("**NO: non-finite element 1**");
Compare(real, lib, nat with { Decoded = float.PositiveInfinity }, probe).ShouldBe("**NO: non-finite element 0**");
// Stable: the two libplctag reads agree, so native reading anything else is a real decoding divergence.
var stableProbe = lib with { };
DifferentialRun.IsLive(lib, stableProbe).ShouldBeFalse();
Compare(real, lib, nat, stableProbe).ShouldBe("**NO: raw**");
Classify(Compare(real, lib, nat, stableProbe)).ShouldBe(Verdict.Unexpected);
Compare(real, lib, lib with { Decoded = 1f }, stableProbe).ShouldBe("**NO: value**");
Compare(real, lib, lib with { }, stableProbe).ShouldBe("yes");
Classify("yes").ShouldBe(Verdict.Equal);
// A missing or failed probe is never live: the full value comparison applies.
Compare(real, lib, nat, null).ShouldBe("**NO: raw**");
Compare(real, lib, nat, new ReadObservation((int)AbStatus.Timeout, "Timeout", null, null)).ShouldBe("**NO: raw**");
}
private async Task<DifferentialResult> RunOrSkipAsync()
{
if (DifferentialTarget.FromEnvironment() is null) Assert.Skip(DifferentialTarget.SkipReason);
return await fixture.GetAsync();
}
internal static string Compare(SelectedTag tag, ReadObservation lib, ReadObservation nat)
internal enum Verdict { Equal, Expected, Live, Unexpected }
/// <summary>Which list a verdict string belongs to. Live tags are neither expected nor unexpected divergences.</summary>
internal static Verdict Classify(string verdict) =>
verdict == "yes" ? Verdict.Equal
: verdict == "live" ? Verdict.Live
: verdict.StartsWith("expected", StringComparison.Ordinal) ? Verdict.Expected
: Verdict.Unexpected;
/// <param name="libProbe">libplctag's second (volatility-probe) read of the tag; null ⇒ treated as stable.</param>
internal static string Compare(SelectedTag tag, ReadObservation lib, ReadObservation nat, ReadObservation? libProbe = null)
{
// Spec §5: native refuses a returned type that disagrees with the authored one; libplctag decodes it anyway.
if (nat.Status == (int)AbStatus.TypeMismatch && lib.Status == (int)AbStatus.Ok)
@@ -232,21 +360,44 @@ public sealed class TransportDifferentialTests(DifferentialRunFixture fixture)
if (!lib.Succeeded || !nat.Succeeded)
return lib.Succeeded == nat.Succeeded ? "yes" : "**NO: error**";
if (lib.DecodeError is not null || nat.DecodeError is not null) return "**NO: decode**";
if (DifferentialRun.IsLive(lib, libProbe))
{
// The value moved between libplctag's own two reads, so native reading a third value proves nothing.
// Everything that does not depend on the value must still agree.
if (nat.Raw is null || nat.Raw.Length != lib.Raw!.Length) return "**NO: raw length**";
if (lib.TypeCode is { } lt && nat.TypeCode is { } nt && lt != nt) return "**NO: type code**";
if (tag.Type is null) return "live"; // raw-only (type unknown): nothing decoded to compare element-wise
if (!DifferentialRun.SameShape(lib.Decoded, nat.Decoded)) return "**NO: shape**";
// Only what MOVED between libplctag's two reads is excused (review r1 I1): every element that held still
// must read the same through native, so a native offset/stride/byte-order defect cannot hide as "live".
return DifferentialRun.LiveElementMismatch(lib.Decoded, libProbe!.Decoded, nat.Decoded) ?? "live";
}
if (lib.Raw is null || nat.Raw is null || !lib.Raw.AsSpan().SequenceEqual(nat.Raw)) return "**NO: raw**";
if (tag.Type is not null && !DifferentialRun.ValuesEqual(lib.Decoded, nat.Decoded)) return "**NO: value**";
return "yes";
}
private static string Detail(ReadObservation o) =>
o.Error ?? o.DecodeError ?? $"{o.Raw?.Length.ToString(CultureInfo.InvariantCulture) ?? "-"} byte(s) {Hex(o.Raw)} = {Show(o.Decoded)}";
/// <param name="full">Every raw byte and every element (live tags: the evidence must be re-checkable by hand).</param>
private static string Detail(ReadObservation o, bool full = false) =>
o.Error ?? o.DecodeError ??
$"{o.Raw?.Length.ToString(CultureInfo.InvariantCulture) ?? "-"} byte(s) {Hex(o.Raw, full)} = {Show(o.Decoded, full)}";
private static string Hex(byte[]? raw) =>
raw is null ? "-" : Convert.ToHexString(raw.AsSpan(0, Math.Min(raw.Length, 32))) + (raw.Length > 32 ? "…" : "");
private static string ProbeColumn(ReadObservation lib, ReadObservation? probe) =>
probe is null ? "-"
: DifferentialRun.IsLive(lib, probe) ? "**live**"
: probe.Succeeded && lib.Succeeded ? "stable"
: StatusName(probe.Status);
private static string Show(object? v) => v switch
private static string Hex(byte[]? raw, bool full = false) =>
raw is null ? "-"
: full || raw.Length <= 32 ? Convert.ToHexString(raw)
: Convert.ToHexString(raw.AsSpan(0, 32)) + "…";
private static string Show(object? v, bool full = false) => v switch
{
null => "null",
Array a => "[" + string.Join(",", a.Cast<object?>().Take(8).Select(Show)) + (a.Length > 8 ? ",…" : "") + "]",
Array a => "[" + string.Join(",", a.Cast<object?>().Take(full ? int.MaxValue : 8).Select(x => Show(x))) +
(!full && a.Length > 8 ? ",…" : "") + "]",
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
_ => v.ToString() ?? "",
};
@@ -0,0 +1,43 @@
using libplctag.NativeImport;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
/// <summary>
/// T21 press pass 1 crash, the variant that killed the test host: a tag the controller does not have. libplctag
/// raises <c>Created</c> with NotFound, so libplctag.NET 1.5.2 throws WITHOUT unregistering its callback, and its
/// <c>Dispose()</c> skips the uninitialised tag. Before the fix the native tag survived with a live callback into a
/// delegate the GC was free to collect; libplctag's <c>atexit</c> shutdown then raised DESTROYED into it ("A callback
/// was made on a garbage collected delegate"). This pins that the native tag is destroyed instead.
/// </summary>
[Trait("Category", "Integration")]
[Trait("Requires", "AbServer")]
public sealed class LibplctagOrphanReleaseLiveTests
{
[AbServerFact]
public async Task A_tag_the_controller_lacks_leaves_no_native_tag_behind()
{
var fixture = new AbServerFixture();
var rt = new LibplctagTagRuntime(new AbCipTagCreateParams(
Gateway: fixture.Host, Port: fixture.Port, CipPath: "1,0", LibplctagPlcAttribute: "controllogix",
TagName: "T21_NoSuchTag", Timeout: TimeSpan.FromSeconds(5)));
try
{
var ex = await Should.ThrowAsync<AbTransportException>(() => rt.InitializeAsync(CancellationToken.None));
ex.Status.ShouldBe(AbStatus.NotFound);
rt.GetStatus().ShouldBe((int)AbStatus.NotFound);
var handle = rt.NativeHandle;
handle.ShouldBeGreaterThan(0);
var exists = plctag.plc_tag_lock(handle) == 0;
if (exists) plctag.plc_tag_unlock(handle);
exists.ShouldBeFalse("a NotFound tag must not survive with its callback registered");
}
finally
{
rt.Dispose();
}
}
}
@@ -202,4 +202,26 @@ public sealed class AbCipDriverProbeTests
result.Ok.ShouldBeTrue();
result.Message!.ShouldContain("probe tag not found");
}
/// <summary>
/// T21: the probe builds its handle from the family profile, so a CompactLogix device with no
/// ConnectionSize opens Large (4002) like libplctag; an explicit device ConnectionSize still wins.
/// </summary>
[Theory]
[InlineData("", 4002)]
[InlineData(",\"connectionSize\":504", 504)]
public async Task CompactLogix_probe_uses_the_profile_default_unless_the_device_overrides_it(string extra, int expected)
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var factory = new FakeAbCipTagFactory();
await new AbCipDriverProbe(factory).ProbeAsync(
$"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\",\"plcFamily\":\"CompactLogix\"{extra}}}]}}",
TimeSpan.FromSeconds(3),
TestContext.Current.CancellationToken);
factory.Tags.Values.ShouldHaveSingleItem().CreationParams.ConnectionSize.ShouldBe(expected);
}
}
@@ -106,7 +106,7 @@ public sealed class AbCipDriverTests
AbCipPlcFamilyProfile.Micro800.SupportsRequestPacking.ShouldBeFalse();
AbCipPlcFamilyProfile.Micro800.SupportsConnectedMessaging.ShouldBeFalse();
AbCipPlcFamilyProfile.CompactLogix.DefaultConnectionSize.ShouldBe(504);
AbCipPlcFamilyProfile.CompactLogix.DefaultConnectionSize.ShouldBe(4002); // Large, as libplctag opens it
AbCipPlcFamilyProfile.GuardLogix.LibplctagPlcAttribute.ShouldBe("controllogix");
}
@@ -0,0 +1,107 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Task 22 review r1: a runtime that refuses an operation with <see cref="AbStatus.Unsupported"/> (the native
/// transport does this before the wire, e.g. a write it cannot type) reaches the client as BadNotSupported —
/// NOT as a comms fault. The handle is kept (one create, never disposed) and driver health is untouched.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipLocalRefusalTests
{
private const string Host = "ab://10.0.0.5/1,0";
private sealed class RefusingTag(AbCipTagCreateParams p, bool refuseRead, bool refuseWrite) : FakeAbCipTag(p)
{
public override Task ReadAsync(CancellationToken cancellationToken) =>
refuseRead
? Task.FromException(new AbTransportException(AbStatus.Unsupported, "refused read"))
: base.ReadAsync(cancellationToken);
public override Task WriteAsync(CancellationToken cancellationToken) =>
refuseWrite
? Task.FromException(new AbTransportException(AbStatus.Unsupported, "refused write"))
: base.WriteAsync(cancellationToken);
}
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory, Func<int> Creates)> NewAsync(
bool refuseRead, bool refuseWrite, AbCipDataType type = AbCipDataType.DInt)
{
var creates = 0;
var factory = new FakeAbCipTagFactory
{
Customise = p => { Interlocked.Increment(ref creates); return new RefusingTag(p, refuseRead, refuseWrite) { Value = 1 }; },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Host)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", Host, "T", type)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
return (drv, factory, () => Volatile.Read(ref creates));
}
#pragma warning disable OTOPCUA0001 // the driver's own status mapping is under test, not resilience wrapping
[Fact]
public async Task A_refused_write_is_BadNotSupported_keeps_the_handle_and_leaves_health_alone()
{
var (drv, factory, creates) = await NewAsync(refuseRead: false, refuseWrite: true);
(await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
var health = drv.GetHealth();
(await drv.WriteAsync([new WriteRequest("T", 5)], CancellationToken.None)).Single().StatusCode
.ShouldBe(AbCipStatusMapper.BadNotSupported);
drv.GetHealth().ShouldBe(health);
factory.Tags["T"].Disposed.ShouldBeFalse();
(await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
creates().ShouldBe(1);
}
[Fact]
public async Task A_refused_read_is_BadNotSupported_keeps_the_handle_and_leaves_health_alone()
{
var (drv, factory, creates) = await NewAsync(refuseRead: true, refuseWrite: false);
var health = drv.GetHealth();
for (var n = 0; n < 2; n++)
(await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode
.ShouldBe(AbCipStatusMapper.BadNotSupported);
drv.GetHealth().ShouldBe(health);
factory.Tags["T"].Disposed.ShouldBeFalse();
creates().ShouldBe(1);
}
/// <summary>Control: any OTHER transport failure is still a comms fault (evicted, Degraded).</summary>
[Fact]
public async Task A_non_refusal_write_failure_still_evicts_and_degrades()
{
var factory = new FakeAbCipTagFactory();
factory.Customise = p => new ThrowingWriteTag(p) { Value = 1 };
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Host)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", Host, "T", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.WriteAsync([new WriteRequest("T", 5)], CancellationToken.None)).Single().StatusCode
.ShouldBe(AbCipStatusMapper.BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
factory.Tags["T"].Disposed.ShouldBeTrue();
}
#pragma warning restore OTOPCUA0001
private sealed class ThrowingWriteTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
public override Task WriteAsync(CancellationToken cancellationToken) =>
Task.FromException(new AbTransportException(AbStatus.BadConnection, "socket closed"));
}
}
@@ -117,6 +117,29 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
factory.Tags["Speed"].CreationParams.ConnectionSize.ShouldBe(4002);
}
/// <summary>
/// T21: a CompactLogix device with no override opens Large (4002), matching libplctag's
/// <c>plc=compactlogix</c>; the per-device override still wins.
/// </summary>
[Theory]
[InlineData(null, 4002)]
[InlineData(504, 504)]
public async Task CompactLogix_ConnectionSize_defaults_to_large_unless_overridden(int? overrideSize, int expected)
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device, AbCipPlcFamily.CompactLogix, ConnectionSize: overrideSize)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Speed"], CancellationToken.None);
factory.Tags["Speed"].CreationParams.ConnectionSize.ShouldBe(expected);
}
/// <summary>Verifies that AllowPacking and ConnectionSize round-trip correctly through JSON parsing.</summary>
[Fact]
public void AbCipDriverFactoryExtensions_ParseOptions_round_trips_AllowPacking_and_ConnectionSize()
@@ -40,22 +40,26 @@ public sealed class AbCipPlcFamilyTests
// ---- CompactLogix ----
/// <summary>Verifies that the CompactLogix profile uses a narrower connection size than ControlLogix.</summary>
/// <summary>
/// Verifies that the CompactLogix profile defaults to a Large Forward Open (4002), matching what
/// libplctag opens for <c>plc=compactlogix</c>. A target that refuses Large falls back to a regular
/// 504 open inside CipConnection (pinned by the Ab.Transport CipConnectionTests fallback tests).
/// </summary>
[Fact]
public void CompactLogix_profile_uses_narrower_connection_size()
public void CompactLogix_profile_defaults_to_a_large_forward_open_like_libplctag()
{
var p = AbCipPlcFamilyProfile.CompactLogix;
p.LibplctagPlcAttribute.ShouldBe("compactlogix");
p.DefaultConnectionSize.ShouldBe(504); // 5069-L3x narrow-window safety
p.DefaultConnectionSize.ShouldBe(4002); // T21: 504 cost the native transport ~3x the round trips per poll
p.DefaultCipPath.ShouldBe("1,0");
p.SupportsRequestPacking.ShouldBeTrue();
p.SupportsConnectedMessaging.ShouldBeTrue();
p.MaxFragmentBytes.ShouldBe(500);
}
/// <summary>Verifies that a CompactLogix device initializes with a narrow connection size.</summary>
/// <summary>Verifies that a CompactLogix device initializes with the Large connection size libplctag opens (T21).</summary>
[Fact]
public async Task CompactLogix_device_initialises_with_narrow_ConnectionSize()
public async Task CompactLogix_device_initialises_with_large_ConnectionSize()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
@@ -65,7 +69,7 @@ public sealed class AbCipPlcFamilyTests
await drv.InitializeAsync("{}", CancellationToken.None);
var profile = drv.GetDeviceState("ab://192.168.1.10/1,0")!.Profile;
profile.DefaultConnectionSize.ShouldBeLessThan(AbCipPlcFamilyProfile.ControlLogix.DefaultConnectionSize);
profile.DefaultConnectionSize.ShouldBe(4002);
profile.MaxFragmentBytes.ShouldBeLessThan(AbCipPlcFamilyProfile.ControlLogix.MaxFragmentBytes);
}
@@ -12,13 +12,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipTransportInitLogTests
{
private static string Config(string transport) =>
$$"""{"transport":"{{transport}}","probe":{"enabled":false},"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"ControlLogix"}]}""";
private static string Config(string? transport) =>
$$"""{{{(transport is null ? "" : $"\"transport\":\"{transport}\",")}}"probe":{"enabled":false},"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"ControlLogix"}]}""";
/// <summary>The init line names the configured-vs-effective transport (Task 22).</summary>
[Theory]
[InlineData("Native", AbCipTransport.Native)]
[InlineData("Libplctag", AbCipTransport.Libplctag)]
public async Task Init_logs_the_transport_through_the_registered_logger_factory(string json, AbCipTransport expected)
[InlineData("Native", "Native (configured)")]
[InlineData("Libplctag", "Libplctag (configured)")]
[InlineData(null, "Native (default)")]
public async Task Init_logs_the_transport_through_the_registered_logger_factory(string? json, string expected)
{
var captured = new List<string>();
using var factory = new CapturingLoggerFactory(captured);
@@ -14,17 +14,23 @@ public sealed class AbCipTransportSwitchTests
private static string Config(string? transportJson) =>
$$"""{{{(transportJson is null ? "" : $"\"transport\":{transportJson},")}}"devices":[{{Device}}]}""";
/// <summary>Transport values select the factory trio; absent defaults to libplctag.</summary>
/// <summary>
/// Transport values select the factory trio; absent parses to null ("use the default") and the driver
/// resolves that to Native (Task 22). An explicit Libplctag still wins.
/// </summary>
[Theory]
[InlineData("\"Native\"", AbCipTransport.Native)]
[InlineData("\"native\"", AbCipTransport.Native)]
[InlineData("\"Libplctag\"", AbCipTransport.Libplctag)]
[InlineData(null, AbCipTransport.Libplctag)]
[InlineData(null, AbCipTransport.Native)]
public void Config_transport_selects_the_factory(string? json, AbCipTransport expected)
{
AbCipDriverFactoryExtensions.ParseOptions("ab1", Config(json)).Transport.ShouldBe(expected);
var parsed = AbCipDriverFactoryExtensions.ParseOptions("ab1", Config(json));
if (json is null) parsed.Transport.ShouldBeNull();
else parsed.Transport.ShouldBe(expected);
var driver = AbCipDriverFactoryExtensions.CreateInstance("ab1", Config(json));
driver.EffectiveTransport.ShouldBe(expected);
if (expected == AbCipTransport.Native)
driver.TagFactory.ShouldBeOfType<NativeAbCipTagFactory>();
else
@@ -60,6 +66,28 @@ public sealed class AbCipTransportSwitchTests
existing.Message.ShouldContain("unknown PlcFamily 'Bogus'");
}
/// <summary>
/// An undefined integer string is rejected, not silently run on a switch's default arm (Task 22 review
/// r1 M2): <c>Enum.TryParse</c> alone accepts "7". A defined numeric ("1") still parses, as before.
/// </summary>
[Fact]
public void An_undefined_numeric_transport_is_rejected()
{
Should.Throw<InvalidOperationException>(
() => AbCipDriverFactoryExtensions.ParseOptions("ab1", Config("\"7\"")))
.Message.ShouldContain("unknown Transport '7'");
AbCipDriverFactoryExtensions.ParseOptions("ab1", Config("\"Native\"")).Transport.ShouldBe(AbCipTransport.Native);
}
/// <summary>A bad driver-level value names the driver config, not a "tag" (Task 22 review r1 M3).</summary>
[Fact]
public void A_bad_transport_names_the_driver_config()
{
var ex = Should.Throw<InvalidOperationException>(
() => AbCipDriverFactoryExtensions.ParseOptions("ab1", Config("\"Bogus\"")));
ex.Message.ShouldStartWith("AB CIP config in 'ab1' has unknown Transport 'Bogus'");
}
/// <summary>An explicitly injected factory wins over the option.</summary>
[Fact]
public void Injected_factory_wins_over_the_option()
@@ -119,6 +147,41 @@ public sealed class AbCipTransportSwitchTests
lib.Message!.ShouldNotContain("Connection lost");
}
/// <summary>
/// The probe resolves an ABSENT transport through the same resolver as the driver: with no key it takes
/// the native path (native failure text), and an explicit Libplctag on the same listener does not.
/// </summary>
[Fact]
public async Task Probe_with_no_transport_resolves_like_the_driver_to_native()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
_ = Task.Run(async () =>
{
while (true)
{
try { (await listener.AcceptTcpClientAsync(TestContext.Current.CancellationToken)).Dispose(); }
catch { return; }
}
}, TestContext.Current.CancellationToken);
var device = $$"""{"hostAddress":"ab://127.0.0.1:{{port}}/1,0"}""";
var probe = new AbCipDriverProbe();
var absent = await probe.ProbeAsync($$"""{"devices":[{{device}}]}""", TimeSpan.FromSeconds(3),
TestContext.Current.CancellationToken);
var lib = await probe.ProbeAsync($$"""{"transport":"Libplctag","devices":[{{device}}]}""", TimeSpan.FromSeconds(3),
TestContext.Current.CancellationToken);
listener.Stop();
AbCipTransportResolver.Resolve(AbCipDriverFactoryExtensions.ParseOptions("p", $$"""{"devices":[{{device}}]}"""))
.ShouldBe(AbCipTransport.Native);
absent.Ok.ShouldBeFalse();
absent.Message!.ShouldContain("CIP handshake failed: Connection lost");
lib.Message!.ShouldNotContain("Connection lost");
}
/// <summary>The probe deserialises with its own string-enum converter; a bad transport is a config error.</summary>
[Fact]
public async Task Probe_with_a_bad_transport_reports_invalid_config()
@@ -128,4 +191,73 @@ public sealed class AbCipTransportSwitchTests
r.Ok.ShouldBeFalse();
r.Message!.ShouldContain("Config JSON is invalid");
}
/// <summary>
/// Final review I1 precedence: explicit key &gt; node default &gt; compiled Native. The pinned rows are
/// the falsification rows — a resolver that ignores the node default returns Native for them.
/// </summary>
[Theory]
[InlineData(null, AbCipTransport.Libplctag, AbCipTransport.Libplctag)]
[InlineData(null, AbCipTransport.Native, AbCipTransport.Native)]
[InlineData(AbCipTransport.Native, AbCipTransport.Libplctag, AbCipTransport.Native)]
[InlineData(AbCipTransport.Libplctag, AbCipTransport.Native, AbCipTransport.Libplctag)]
public void Explicit_key_beats_node_default_beats_compiled_default(
AbCipTransport? configured, AbCipTransport nodeDefault, AbCipTransport expected)
{
AbCipTransportResolver.Resolve(configured, nodeDefault).ShouldBe(expected);
var json = configured is null ? Config(null) : Config($"\"{configured}\"");
AbCipDriverFactoryExtensions.CreateInstance("ab1", json, loggerFactory: null, defaultTransport: nodeDefault)
.EffectiveTransport.ShouldBe(expected);
}
/// <summary>The init-log rendering distinguishes the compiled default from a node pin.</summary>
[Fact]
public void Describe_distinguishes_compiled_default_node_default_and_configured()
{
AbCipTransportResolver.Describe(null).ShouldBe("Native (default)");
AbCipTransportResolver.Describe(null, AbCipTransport.Libplctag).ShouldBe("Libplctag (node default)");
AbCipTransportResolver.Describe(AbCipTransport.Native, AbCipTransport.Libplctag).ShouldBe("Native (configured)");
}
[Theory]
[InlineData("Native", true)]
[InlineData("libplctag", true)]
[InlineData(" Libplctag ", true)]
[InlineData("1", false)]
[InlineData("Native,Libplctag", false)]
[InlineData("", false)]
[InlineData("Bogus", false)]
public void TryParse_accepts_names_only(string raw, bool ok) =>
AbCipTransportResolver.TryParse(raw, out _).ShouldBe(ok);
/// <summary>
/// The probe honours its node default for an absent key: pinned to Libplctag it does NOT produce the
/// native failure text against the same listener the unpinned probe does (falsification control).
/// </summary>
[Fact]
public async Task Probe_with_no_transport_uses_the_node_default()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
_ = Task.Run(async () =>
{
while (true)
{
try { (await listener.AcceptTcpClientAsync(TestContext.Current.CancellationToken)).Dispose(); }
catch { return; }
}
}, TestContext.Current.CancellationToken);
var cfg = $$"""{"devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0"}]}""";
var pinned = await new AbCipDriverProbe(defaultTransport: AbCipTransport.Libplctag)
.ProbeAsync(cfg, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
var unpinned = await new AbCipDriverProbe()
.ProbeAsync(cfg, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
listener.Stop();
unpinned.Message!.ShouldContain("CIP handshake failed: Connection lost");
pinned.Ok.ShouldBeFalse();
pinned.Message!.ShouldNotContain("Connection lost");
}
}
@@ -0,0 +1,159 @@
using Microsoft.Extensions.Logging;
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.Ab.Transport.Tests.Fakes;
using CapturingLogger = ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.AbCipLoggingTests.CapturingLogger<ZB.MOM.WW.OtOpcUa.Driver.AbCip.AbCipDriver>;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Final review I4: a type mismatch (the controller answered with a type that disagrees with the authored
/// one) is a deterministic configuration refusal, handled like <see cref="AbStatus.Unsupported"/>:
/// <c>BadTypeMismatch</c>, the handle is KEPT (one create across N polls), health is unchanged, and the
/// Warning is logged once per tag — not per poll. Before the fix each poll evicted the runtime, so a sole
/// lease holder cost a Forward Close + Forward Open on every poll and the driver sat Degraded. The
/// control tests (a genuine transport failure still evicts and degrades) live in
/// <see cref="AbCipLocalRefusalTests"/> and below.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipTypeMismatchTests
{
private const string Host = "ab://10.0.0.5/1,0";
private const int Polls = 4;
/// <summary>A fake whose read completes but reports <see cref="AbStatus.TypeMismatch"/> (the native
/// runtime's same-width check), and whose write throws it (the native write gate / CIP 0xFF 0x2107).</summary>
private sealed class MismatchTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
public override async Task ReadAsync(CancellationToken cancellationToken)
{
await base.ReadAsync(cancellationToken);
Status = (int)AbStatus.TypeMismatch;
}
public override Task WriteAsync(CancellationToken cancellationToken) =>
Task.FromException(new AbTransportException(AbStatus.TypeMismatch, "REAL on the controller, DINT authored"));
}
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory, Func<int> Creates, CapturingLogger Log)>
NewAsync()
{
var creates = 0;
var factory = new FakeAbCipTagFactory
{
Customise = p => { Interlocked.Increment(ref creates); return new MismatchTag(p) { Value = 1 }; },
};
var log = new CapturingLogger();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Host)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", Host, "T", AbCipDataType.DInt, Writable: true)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-i4", factory, logger: log);
await drv.InitializeAsync("{}", CancellationToken.None);
return (drv, factory, () => Volatile.Read(ref creates), log);
}
private static int MismatchWarnings(CapturingLogger log) =>
log.Entries.Count(e => e.Level == LogLevel.Warning && e.Message.Contains("logged once per tag"));
#pragma warning disable OTOPCUA0001 // the driver's own status handling is under test, not resilience wrapping
[Fact]
public async Task A_type_mismatched_read_keeps_the_handle_leaves_health_alone_and_warns_once()
{
var (drv, factory, creates, log) = await NewAsync();
var health = drv.GetHealth();
for (var n = 0; n < Polls; n++)
(await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode
.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
creates().ShouldBe(1);
factory.Tags["T"].Disposed.ShouldBeFalse();
factory.Tags["T"].ReadCount.ShouldBe(Polls); // positive control: every poll did reach the runtime
drv.GetHealth().ShouldBe(health);
MismatchWarnings(log).ShouldBe(1);
}
[Fact]
public async Task A_type_mismatched_write_keeps_the_handle_leaves_health_alone_and_warns_once()
{
var (drv, factory, creates, log) = await NewAsync();
var health = drv.GetHealth();
for (var n = 0; n < Polls; n++)
(await drv.WriteAsync([new WriteRequest("T", 5)], CancellationToken.None)).Single().StatusCode
.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
creates().ShouldBe(1);
factory.Tags["T"].Disposed.ShouldBeFalse();
drv.GetHealth().ShouldBe(health);
MismatchWarnings(log).ShouldBe(1);
}
/// <summary>Control: a genuine transport failure on a READ still evicts and degrades.</summary>
[Fact]
public async Task A_transport_failure_read_still_evicts_and_degrades()
{
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection } };
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Host)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", Host, "T", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-i4", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode
.ShouldNotBe(AbCipStatusMapper.BadTypeMismatch);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
factory.Tags["T"].Disposed.ShouldBeTrue();
}
/// <summary>
/// End to end over the REAL native runtime + a private <see cref="DeviceSessionPool"/> against a fake
/// CIP target that answers every read with a REAL for a DINT-authored tag. The tag is the only lease
/// holder (probe off), so an evict would be the LAST release: Forward Close, then a throttled Forward
/// Open on the next poll. One Forward Open across N polls and no Forward Close proves there is no
/// connection churn.
/// </summary>
[Fact]
public async Task A_native_type_mismatch_causes_no_connection_churn()
{
await using var target = new FakeCipTarget();
// Read Tag Fragmented (0x52) → success reply typed REAL (0x00CA) with 4 data bytes.
target.ConnectedHandler = req => Task.FromResult<byte[]?>(
req[0] == 0x52 ? [0xD2, 0x00, 0x00, 0x00, 0xCA, 0x00, 0x00, 0x00, 0x80, 0x3F] : target.ServiceReply(req));
var pool = new DeviceSessionPool();
var host = $"ab://127.0.0.1:{target.Port}/1,0";
var log = new CapturingLogger();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", host, "T", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions { Enabled = false },
Timeout = TimeSpan.FromSeconds(5),
}, "drv-i4-native", new NativeAbCipTagFactory(pool), logger: log);
await drv.InitializeAsync("{}", CancellationToken.None);
var health = drv.GetHealth();
try
{
for (var n = 0; n < Polls; n++)
(await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode
.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
target.ForwardOpens.ShouldBe(1);
target.ForwardCloses.ShouldBe(0);
target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(Polls); // positive control: polls hit the wire
drv.GetHealth().ShouldBe(health);
MismatchWarnings(log).ShouldBe(1);
}
finally
{
await drv.ShutdownAsync(CancellationToken.None);
}
}
#pragma warning restore OTOPCUA0001
}
@@ -0,0 +1,119 @@
using libplctag.NativeImport;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// T21 press pass 1: a libplctag tag whose initialisation FAILED used to leave its native tag behind, because
/// libplctag.NET 1.5.2's <c>Dispose()</c> only destroys an initialised tag. Its callback delegate then died with
/// the wrapper, and libplctag's <c>atexit</c> shutdown raised DESTROYED into it: "A callback was made on a garbage
/// collected delegate". These pin that a failed initialisation destroys the native tag before it propagates.
/// The NotFound variant (the one that crashed, callback still registered) needs a controller and lives in
/// <c>AbCip.IntegrationTests/LibplctagOrphanReleaseLiveTests</c>; this offline one is the refused-connection
/// variant, whose native tag otherwise keeps reconnecting in the background for the life of the process.
/// </summary>
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class LibplctagOrphanReleaseCollection
{
/// <summary>The release counters and the rooting seam are process-wide: nothing else may run alongside.</summary>
public const string Name = "libplctag orphan release";
}
[Collection(LibplctagOrphanReleaseCollection.Name)]
public sealed class LibplctagOrphanReleaseTests
{
private static AbCipTagCreateParams Refused(string tagName = "TestDINT") => new(
Gateway: "127.0.0.1", Port: 1, CipPath: "1,0", LibplctagPlcAttribute: "controllogix",
TagName: tagName, Timeout: TimeSpan.FromMilliseconds(300));
[Fact]
public void The_pinned_libplctag_wrapper_still_exposes_the_native_handle()
{
// A libplctag bump that renames Tag._tag / NativeTagWrapper.nativeTagHandle disables the release (the tag is
// then rooted for the process lifetime instead). Fail here so the bump is reviewed against the release.
LibplctagOrphanRelease.CanReadHandles.ShouldBeTrue();
}
/// <summary>
/// Final review M2: the handle field's TYPE is pinned explicitly, not just its presence. A retype (IntPtr,
/// long) now degrades to the rooting fallback instead of an InvalidCastException inside a catch block,
/// and this names the reason when it happens.
/// </summary>
[Fact]
public void The_pinned_wrapper_handle_field_is_still_an_int()
{
const System.Reflection.BindingFlags priv =
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance;
var wrapper = typeof(libplctag.Tag).GetField("_tag", priv).ShouldNotBeNull();
wrapper.FieldType.GetField("nativeTagHandle", priv).ShouldNotBeNull().FieldType.ShouldBe(typeof(int));
}
/// <summary>HandleOf never throws (it runs inside the runtimes' catch blocks); a never-created tag reads 0.</summary>
[Fact]
public void HandleOf_a_fresh_tag_is_zero_and_does_not_throw()
{
using var tag = new libplctag.Tag();
LibplctagOrphanRelease.HandleOf(tag).ShouldBe(0);
}
[Fact]
public async Task A_failed_initialisation_destroys_the_native_tag_it_created()
{
// Port 1 on loopback refuses the connection; libplctag keeps retrying until the timeout.
var rt = new LibplctagTagRuntime(new AbCipTagCreateParams(
Gateway: "127.0.0.1", Port: 1, CipPath: "1,0", LibplctagPlcAttribute: "controllogix",
TagName: "TestDINT", Timeout: TimeSpan.FromMilliseconds(300)));
try
{
await Should.ThrowAsync<AbTransportException>(() => rt.InitializeAsync(CancellationToken.None));
var handle = rt.NativeHandle;
handle.ShouldBeGreaterThan(0, "plc_tag_create_ex returned a handle before the failure");
NativeTagExists(handle).ShouldBeFalse("the failed tag's native handle must be destroyed, not orphaned");
// The status the tag held is kept, not replaced by the destroyed handle's NotFound.
rt.GetStatus().ShouldNotBe((int)AbStatus.NotFound);
// Review r1 M2: the wrapper no longer holds the destroyed id, so a repeat release cannot touch it.
rt.WrapperHandle.ShouldBe(0);
}
finally
{
rt.Dispose();
}
}
/// <summary>
/// Review r1 M1: when the handle cannot be read (a renamed wrapper field), the failed tag is ROOTED — its callback
/// delegate can then never be collected — and nothing is destroyed.
/// </summary>
[Fact]
public async Task Without_a_readable_handle_the_failed_tag_is_rooted_instead()
{
var rooted = LibplctagOrphanRelease.RootedCount;
var released = LibplctagOrphanRelease.Released;
var rt = new LibplctagTagRuntime(Refused());
LibplctagOrphanRelease.ForceRootingForTest = true;
try
{
await Should.ThrowAsync<AbTransportException>(() => rt.InitializeAsync(CancellationToken.None));
LibplctagOrphanRelease.RootedCount.ShouldBe(rooted + 1);
LibplctagOrphanRelease.Released.ShouldBe(released);
}
finally
{
LibplctagOrphanRelease.ForceRootingForTest = false;
// Clean up the native tag this test deliberately leaked (its callback was unregistered by the timeout).
var handle = rt.NativeHandle;
if (handle > 0) plctag.plc_tag_destroy(handle);
rt.Dispose();
}
}
/// <summary>plc_tag_lock answers PLCTAG_ERR_NOT_FOUND for a handle the library no longer holds.</summary>
internal static bool NativeTagExists(int handle)
{
if (plctag.plc_tag_lock(handle) != 0) return false;
plctag.plc_tag_unlock(handle);
return true;
}
}
@@ -34,6 +34,8 @@
<ItemGroup>
<!-- Native transport test fake, source-linked so it stays internal (Task 12). -->
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\Fakes\FakeCipChannel.cs" Link="Fakes\FakeCipChannel.cs" />
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\Fakes\FakeEipServer.cs" Link="Fakes\FakeEipServer.cs" />
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\Fakes\FakeCipTarget.cs" Link="Fakes\FakeCipTarget.cs" />
</ItemGroup>
</Project>
@@ -39,9 +39,12 @@ public sealed class AbLegacyReinitConfigAdoptionTests
[Fact]
public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_options()
{
// Pinned to libplctag (Task 22): this init starts the default probe loop against 10.0.0.1 with no
// injected factory, and was written and proven on libplctag. Pinning keeps it off the process-wide
// native DeviceSessionPool rather than silently changing what it exercises.
var driver = AbLegacyDriverFactoryExtensions.CreateInstance(
"ab1",
"""{"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}]}""",
"""{"transport":"Libplctag","devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}]}""",
loggerFactory: null);
await Should.NotThrowAsync(() => driver.ReinitializeAsync("{}", CancellationToken.None));
@@ -12,44 +12,102 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
[Trait("Category", "Unit")]
public sealed class AbLegacyTransportInitLogTests
{
private static string Config(string transport) =>
$$"""{"transport":"{{transport}}","probe":{"enabled":false},"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}]}""";
private static string Config(string? transport, string family = "Slc500") =>
$$"""{{{(transport is null ? "" : $"\"transport\":\"{transport}\",")}}"probe":{"enabled":false},"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"{{family}}"}]}""";
/// <summary>The init line names the configured-vs-effective transport (final review I3: default = Libplctag).</summary>
[Theory]
[InlineData("Native", AbLegacyTransport.Native)]
[InlineData("Libplctag", AbLegacyTransport.Libplctag)]
public async Task Init_logs_the_transport_through_the_registered_logger_factory(string json, AbLegacyTransport expected)
[InlineData("Native", "Slc500", "Native (configured)")]
[InlineData("Libplctag", "Slc500", "Libplctag (configured)")]
[InlineData(null, "Slc500", "Libplctag (default)")]
[InlineData(null, "Plc5", "Libplctag (default)")]
public async Task Init_logs_the_transport_through_the_registered_logger_factory(
string? json, string family, string expected)
{
var captured = new List<string>();
var captured = await InitAndCaptureAsync(Config(json, family));
captured.ShouldContain(m => m.Message.Contains("drv-t19") && m.Message.Contains($"transport={expected}"));
}
/// <summary>
/// An explicit Native against a PLC-5 / LogixPccc device is an allowed operator choice, but logs ONE
/// Warning naming the limitation. The defaulted and explicit-libplctag rows are the falsification
/// controls: same device families, no warning.
/// </summary>
[Theory]
[InlineData("Native", "Plc5", true)]
[InlineData("Native", "LogixPccc", true)]
[InlineData(null, "Plc5", false)]
[InlineData(null, "Slc500", false)]
[InlineData("Libplctag", "LogixPccc", false)]
[InlineData("Native", "Slc500", false)]
public async Task Explicit_Native_on_a_limited_family_warns_once(string? json, string family, bool warns)
{
var captured = await InitAndCaptureAsync(Config(json, family));
var warnings = captured.Where(m => m.Level == LogLevel.Warning && m.Message.Contains("native support is limited"))
.ToList();
warnings.Count.ShouldBe(warns ? 1 : 0);
if (warns) warnings[0].Message.ShouldContain(family);
}
/// <summary>
/// The Warning is once per INSTANCE, not per init: every comms-loss reconnect retry re-runs init
/// (Task 22 review r1 M4). Three inits, one warning.
/// </summary>
[Fact]
public async Task The_limited_family_warning_is_logged_once_per_instance_across_reinits()
{
var config = Config("Native", "Plc5");
var captured = new List<(LogLevel, string)>();
using var factory = new CapturingLoggerFactory(captured);
var driver = AbLegacyDriverFactoryExtensions.CreateInstance("drv-t19", Config(json), factory);
var driver = AbLegacyDriverFactoryExtensions.CreateInstance("drv-t19", config, factory);
try
{
await driver.InitializeAsync(Config(json), TestContext.Current.CancellationToken);
await driver.InitializeAsync(config, TestContext.Current.CancellationToken);
await driver.ReinitializeAsync(config, TestContext.Current.CancellationToken);
await driver.ReinitializeAsync(config, TestContext.Current.CancellationToken);
}
finally
{
await driver.ShutdownAsync(TestContext.Current.CancellationToken);
}
captured.ShouldContain(m => m.Contains("drv-t19") && m.Contains($"transport={expected}"));
List<(LogLevel Level, string Message)> snapshot;
lock (captured) snapshot = [.. captured];
snapshot.Count(m => m.Message.Contains("initializing")).ShouldBe(3); // positive control: three inits ran
snapshot.Count(m => m.Level == LogLevel.Warning && m.Message.Contains("native support is limited")).ShouldBe(1);
}
private sealed class CapturingLoggerFactory(List<string> sink) : ILoggerFactory
private static async Task<List<(LogLevel Level, string Message)>> InitAndCaptureAsync(string config)
{
var captured = new List<(LogLevel, string)>();
using var factory = new CapturingLoggerFactory(captured);
var driver = AbLegacyDriverFactoryExtensions.CreateInstance("drv-t19", config, factory);
try
{
await driver.InitializeAsync(config, TestContext.Current.CancellationToken);
}
finally
{
await driver.ShutdownAsync(TestContext.Current.CancellationToken);
}
lock (captured) return [.. captured];
}
private sealed class CapturingLoggerFactory(List<(LogLevel, string)> sink) : ILoggerFactory
{
public ILogger CreateLogger(string categoryName) => new CapturingLogger(sink);
public void AddProvider(ILoggerProvider provider) { }
public void Dispose() { }
}
private sealed class CapturingLogger(List<string> sink) : ILogger
private sealed class CapturingLogger(List<(LogLevel, string)> sink) : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
lock (sink) sink.Add(formatter(state, exception));
lock (sink) sink.Add((logLevel, formatter(state, exception)));
}
}
}
@@ -14,7 +14,18 @@ public sealed class AbLegacyTransportSwitchTests
private static string Config(string? transportJson) =>
$$"""{{{(transportJson is null ? "" : $"\"transport\":{transportJson},")}}"devices":[{{Device}}]}""";
/// <summary>Transport values select the factory type; absent defaults to libplctag.</summary>
private static string Config(string? transportJson, params string[] families)
{
var devices = string.Join(",", families.Select((f, i) =>
$"{{\"hostAddress\":\"ab://10.0.0.{i + 1}:44818/1,0\",\"plcFamily\":\"{f}\"}}"));
var transport = transportJson is null ? "" : $"\"transport\":{transportJson},";
return $"{{{transport}\"devices\":[{devices}]}}";
}
/// <summary>
/// Transport values select the factory type; absent parses to null ("use the default") and the driver
/// resolves that to Libplctag (final review I3 — Native is opt-in). An explicit Native still wins.
/// </summary>
[Theory]
[InlineData("\"Native\"", AbLegacyTransport.Native)]
[InlineData("\"native\"", AbLegacyTransport.Native)]
@@ -22,15 +33,62 @@ public sealed class AbLegacyTransportSwitchTests
[InlineData(null, AbLegacyTransport.Libplctag)]
public void Config_transport_selects_the_factory(string? json, AbLegacyTransport expected)
{
AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config(json)).Transport.ShouldBe(expected);
var parsed = AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config(json));
if (json is null) parsed.Transport.ShouldBeNull();
else parsed.Transport.ShouldBe(expected);
var driver = AbLegacyDriverFactoryExtensions.CreateInstance("ab1", Config(json));
driver.EffectiveTransport.ShouldBe(expected);
if (expected == AbLegacyTransport.Native)
driver.TagFactory.ShouldBeOfType<NativeAbLegacyTagFactory>();
else
driver.TagFactory.ShouldBeOfType<LibplctagLegacyTagFactory>();
}
/// <summary>
/// Final review I3: an absent key resolves EVERY family to libplctag — the SLC/MicroLogix rows are the
/// ones that change (they defaulted Native before), so they are the falsification rows: reverting the
/// resolver to "Native unless PLC-5/LogixPccc" turns them red.
/// </summary>
[Theory]
[InlineData("Plc5")]
[InlineData("LogixPccc")]
[InlineData("Slc500", "Plc5")]
[InlineData("Slc500")]
[InlineData("MicroLogix")]
[InlineData("Slc500", "MicroLogix")]
public void Absent_transport_resolves_libplctag_for_every_family(params string[] families)
{
var json = Config(null, families);
AbLegacyTransportResolver.Resolve(AbLegacyDriverFactoryExtensions.ParseOptions("ab1", json))
.ShouldBe(AbLegacyTransport.Libplctag);
var driver = AbLegacyDriverFactoryExtensions.CreateInstance("ab1", json);
driver.EffectiveTransport.ShouldBe(AbLegacyTransport.Libplctag);
driver.TagFactory.ShouldBeOfType<LibplctagLegacyTagFactory>();
}
/// <summary>The init-log rendering names the default as such, and an explicit choice as configured.</summary>
[Fact]
public void Describe_names_default_and_configured()
{
AbLegacyTransportResolver.Describe(null).ShouldBe("Libplctag (default)");
AbLegacyTransportResolver.Describe(AbLegacyTransport.Native).ShouldBe("Native (configured)");
AbLegacyTransportResolver.Describe(AbLegacyTransport.Libplctag).ShouldBe("Libplctag (configured)");
}
/// <summary>An explicit value always wins, for every family.</summary>
[Theory]
[InlineData("\"Native\"", "Plc5", AbLegacyTransport.Native)]
[InlineData("\"Native\"", "LogixPccc", AbLegacyTransport.Native)]
[InlineData("\"Native\"", "Slc500", AbLegacyTransport.Native)]
[InlineData("\"Libplctag\"", "Slc500", AbLegacyTransport.Libplctag)]
public void Explicit_transport_wins_over_the_exclusion(string json, string family, AbLegacyTransport expected)
{
var driver = AbLegacyDriverFactoryExtensions.CreateInstance("ab1", Config(json, family));
driver.EffectiveTransport.ShouldBe(expected);
}
/// <summary>A bad value fails the way an existing bad enum field (PlcFamily) does.</summary>
[Fact]
public void Bad_transport_fails_like_a_bad_PlcFamily()
@@ -45,6 +103,19 @@ public sealed class AbLegacyTransportSwitchTests
existing.Message.ShouldContain("unknown PlcFamily 'Bogus'");
}
/// <summary>
/// An undefined integer string is rejected, not silently run on a switch's default arm (Task 22 review
/// r1 M2): <c>Enum.TryParse</c> alone accepts "7". A defined numeric ("1") still parses, as before.
/// </summary>
[Fact]
public void An_undefined_numeric_transport_is_rejected()
{
Should.Throw<InvalidOperationException>(
() => AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config("\"7\"")))
.Message.ShouldContain("unknown Transport '7'");
AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config("\"Native\"")).Transport.ShouldBe(AbLegacyTransport.Native);
}
/// <summary>An explicitly injected factory wins over the option.</summary>
[Fact]
public void Injected_factory_wins_over_the_option()
@@ -81,14 +152,14 @@ public sealed class AbLegacyTransportSwitchTests
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var accept = Task.Run(async () =>
_ = Task.Run(async () =>
{
while (true)
{
try { (await listener.AcceptTcpClientAsync(TestContext.Current.CancellationToken)).Dispose(); }
catch { return; }
}
});
}, TestContext.Current.CancellationToken);
string Cfg(string t) =>
$$"""{"transport":"{{t}}","devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0"}]}""";
@@ -102,7 +173,48 @@ public sealed class AbLegacyTransportSwitchTests
native.Message.ShouldNotBeNull();
// "Connection lost" is the managed EncapsulationClient's wording; libplctag reports a bare status name.
native.Message!.ShouldContain("PCCC handshake failed: Connection lost");
lib.Message.ShouldNotContain("Connection lost");
lib.Message!.ShouldNotContain("Connection lost");
}
/// <summary>
/// The probe resolves an ABSENT transport through the same resolver as the driver: an SLC 500 with no
/// key takes libplctag (no native failure text) exactly as a PLC-5 does, while an explicit Native on the
/// same listener produces the native text — the control that proves the text discriminates.
/// </summary>
[Fact]
public async Task Probe_with_no_transport_resolves_like_the_driver()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
_ = Task.Run(async () =>
{
while (true)
{
try { (await listener.AcceptTcpClientAsync(TestContext.Current.CancellationToken)).Dispose(); }
catch { return; }
}
}, TestContext.Current.CancellationToken);
string Cfg(string family) =>
$$"""{"devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0","plcFamily":"{{family}}"}]}""";
var nativeCfg =
$$"""{"transport":"Native","devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0","plcFamily":"Slc500"}]}""";
var probe = new AbLegacyDriverProbe();
var slc = await probe.ProbeAsync(Cfg("Slc500"), TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
var plc5 = await probe.ProbeAsync(Cfg("Plc5"), TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
var native = await probe.ProbeAsync(nativeCfg, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
listener.Stop();
AbLegacyTransportResolver.Resolve(AbLegacyDriverFactoryExtensions.ParseOptions("p", Cfg("Slc500")))
.ShouldBe(AbLegacyTransport.Libplctag);
AbLegacyTransportResolver.Resolve(AbLegacyDriverFactoryExtensions.ParseOptions("p", Cfg("Plc5")))
.ShouldBe(AbLegacyTransport.Libplctag);
slc.Ok.ShouldBeFalse();
slc.Message!.ShouldNotContain("Connection lost");
plc5.Message!.ShouldNotContain("Connection lost");
native.Message!.ShouldContain("PCCC handshake failed: Connection lost");
}
/// <summary>The probe deserialises with its own string-enum converter; a bad transport is a config error.</summary>
@@ -0,0 +1,63 @@
using libplctag.NativeImport;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// T21 press pass 1, AbLegacy side: a failed libplctag initialisation must destroy the native tag it created
/// (libplctag.NET 1.5.2's <c>Dispose()</c> skips an uninitialised tag, leaving a native tag whose callback can fire
/// into a collected delegate and kill the process). See <c>LibplctagOrphanRelease</c>.
/// </summary>
public sealed class LibplctagLegacyOrphanReleaseTests
{
[Fact]
public void The_pinned_libplctag_wrapper_still_exposes_the_native_handle() =>
LibplctagOrphanRelease.CanReadHandles.ShouldBeTrue();
/// <summary>
/// Final review M2: the handle field's TYPE is pinned explicitly, not just its presence. A retype (IntPtr,
/// long) now degrades to the rooting fallback instead of an InvalidCastException inside a catch block,
/// and this names the reason when it happens.
/// </summary>
[Fact]
public void The_pinned_wrapper_handle_field_is_still_an_int()
{
const System.Reflection.BindingFlags priv =
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance;
var wrapper = typeof(libplctag.Tag).GetField("_tag", priv).ShouldNotBeNull();
wrapper.FieldType.GetField("nativeTagHandle", priv).ShouldNotBeNull().FieldType.ShouldBe(typeof(int));
}
/// <summary>HandleOf never throws (it runs inside the runtimes' catch blocks); a never-created tag reads 0.</summary>
[Fact]
public void HandleOf_a_fresh_tag_is_zero_and_does_not_throw()
{
using var tag = new libplctag.Tag();
LibplctagOrphanRelease.HandleOf(tag).ShouldBe(0);
}
[Fact]
public async Task A_failed_initialisation_destroys_the_native_tag_it_created()
{
var rt = new LibplctagLegacyTagRuntime(new AbLegacyTagCreateParams(
Gateway: "127.0.0.1", Port: 1, CipPath: "1,0", LibplctagPlcAttribute: "slc500",
TagName: "N7:0", Timeout: TimeSpan.FromMilliseconds(300)));
try
{
await Should.ThrowAsync<AbTransportException>(() => rt.InitializeAsync(CancellationToken.None));
var handle = rt.NativeHandle;
handle.ShouldBeGreaterThan(0, "plc_tag_create_ex returned a handle before the failure");
var exists = plctag.plc_tag_lock(handle) == 0;
if (exists) plctag.plc_tag_unlock(handle);
exists.ShouldBeFalse("the failed tag's native handle must be destroyed, not orphaned");
rt.GetStatus().ShouldNotBe((int)AbStatus.NotFound);
rt.WrapperHandle.ShouldBe(0); // review r1 M2: a repeat release cannot touch the destroyed id
}
finally
{
rt.Dispose();
}
}
}
@@ -1,6 +1,8 @@
using System.Buffers.Binary;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
@@ -380,6 +382,131 @@ public sealed class NativeAbLegacyTagRuntimeTests
h.Channel.SentCount.ShouldBe(0);
}
// These tests call the driver's WriteAsync/ReadAsync directly to pin its OWN status mapping; resilience
// wrapping (CapabilityInvoker) is out of scope and would retry/mask the very status under test.
#pragma warning disable OTOPCUA0001
/// <summary>Native runtimes over the harness channel; counts creates so a test can prove a handle was KEPT.</summary>
private sealed class HarnessFactory(Harness h) : IAbLegacyTagFactory
{
public int Creates;
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p)
{
Interlocked.Increment(ref Creates);
return new NativeAbLegacyTagRuntime(p, h.Scheduler, Vendor, Serial);
}
}
private const string Host = "ab://10.0.0.1:44818/1,0";
private static async Task<AbLegacyDriver> DriverAsync(
HarnessFactory factory, AbLegacyPlcFamily family, AbLegacyTagDefinition tag)
{
var driver = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host, family)],
RawTags = AbLegacyRawTags.Entries([tag]),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-sb", factory);
await driver.InitializeAsync("{}", CancellationToken.None);
return driver;
}
/// <summary>
/// Task 22 review r1 / #571: a native status-bit sub-element write is refused locally and reaches the
/// client as BadNotSupported — NOT a comms fault: nothing on the wire, the tag handle is kept (no
/// re-create), and driver health is untouched. The tag is read first so its handle exists and the
/// wire count isolates the write.
/// </summary>
[Theory]
[InlineData("T4:0.DN")]
[InlineData("C5:0.CU")]
[InlineData("R6:0.EN")]
public async Task A_status_bit_write_is_BadNotSupported_without_wire_eviction_or_degraded_health(string address)
{
await using var h = new Harness("slc500", "N7:0").Replies(Words(0));
var factory = new HarnessFactory(h);
var driver = await DriverAsync(factory, AbLegacyPlcFamily.Slc500,
new AbLegacyTagDefinition("Bit", Host, address, AbLegacyDataType.Bit));
(await driver.ReadAsync(["Bit"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var health = driver.GetHealth();
var sent = h.Channel.SentCount;
var results = await driver.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
h.Channel.SentCount.ShouldBe(sent); // zero wire requests for the refused write
driver.GetHealth().ShouldBe(health); // health unchanged (not Degraded)
(await driver.ReadAsync(["Bit"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
factory.Creates.ShouldBe(1); // handle kept: the read after reused it
await driver.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// #571, read side: a native PLC-5 non-N read is refused locally — BadNotSupported, nothing on the wire,
/// health unchanged, and the refused handle is KEPT (one create across repeated reads, so a poll does
/// not re-lease the device connection every cycle).
/// </summary>
[Fact]
public async Task A_PLC5_non_N_read_is_BadNotSupported_without_wire_eviction_or_degraded_health()
{
await using var h = new Harness("plc5", "N7:0").Replies(Words(0));
var factory = new HarnessFactory(h);
var driver = await DriverAsync(factory, AbLegacyPlcFamily.Plc5,
new AbLegacyTagDefinition("F", Host, "F8:0", AbLegacyDataType.Float));
var health = driver.GetHealth();
for (var n = 0; n < 3; n++)
(await driver.ReadAsync(["F"], CancellationToken.None)).Single().StatusCode
.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
h.Channel.SentCount.ShouldBe(0);
driver.GetHealth().ShouldBe(health);
factory.Creates.ShouldBe(1);
await driver.ShutdownAsync(CancellationToken.None);
}
/// <summary>#571, write side: a native PLC-5 write is refused locally — BadNotSupported, no write on the wire,
/// handle kept, health unchanged.</summary>
[Fact]
public async Task A_PLC5_write_is_BadNotSupported_without_wire_eviction_or_degraded_health()
{
await using var h = new Harness("plc5", "N7:0").Replies(Words(0));
var factory = new HarnessFactory(h);
var driver = await DriverAsync(factory, AbLegacyPlcFamily.Plc5,
new AbLegacyTagDefinition("N", Host, "N7:0", AbLegacyDataType.Int));
(await driver.ReadAsync(["N"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var health = driver.GetHealth();
var sent = h.Channel.SentCount;
(await driver.WriteAsync([new WriteRequest("N", (short)5)], CancellationToken.None)).Single().StatusCode
.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
h.Channel.SentCount.ShouldBe(sent);
driver.GetHealth().ShouldBe(health);
(await driver.ReadAsync(["N"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
factory.Creates.ShouldBe(1);
await driver.ShutdownAsync(CancellationToken.None);
}
/// <summary>Positive control for the wire-count assertions above: the same driver path for a WORD sub-element
/// does send a typed write (fnc 0xAA), so the harness can see one.</summary>
[Fact]
public async Task A_word_sub_element_write_through_the_driver_sends_a_typed_write()
{
await using var h = new Harness("slc500", "N7:0").Replies(Words(0));
var driver = await DriverAsync(new HarnessFactory(h), AbLegacyPlcFamily.Slc500,
new AbLegacyTagDefinition("Pre", Host, "T4:0.PRE", AbLegacyDataType.Int));
var results = await driver.WriteAsync([new WriteRequest("Pre", (short)5)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
lock (h.Channel.Sent) h.Channel.Sent.ShouldContain(r => FncOf(r) == 0xAA);
await driver.ShutdownAsync(CancellationToken.None);
}
#pragma warning restore OTOPCUA0001
[Theory]
[InlineData("T4:0.PRE")]
[InlineData("T4:0.ACC")]
@@ -129,13 +129,14 @@ internal class FakeTwinCATClient : ITwinCATClient
/// <param name="symbolPath">The path to the symbol to watch.</param>
/// <param name="type">The data type of the symbol.</param>
/// <param name="bitIndex">The optional bit index for bit-level notifications.</param>
/// <param name="arrayCount">The authored array length for an array tag; null for a scalar.</param>
/// <param name="cycleTime">The sampling cycle time.</param>
/// <param name="maxDelayMs">The maximum delay in milliseconds.</param>
/// <param name="onChange">The callback to invoke on value change.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that returns a notification handle.</returns>
public virtual async Task<ITwinCATNotificationHandle> AddNotificationAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime,
string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, TimeSpan cycleTime,
int maxDelayMs, Action<string, object?> onChange, CancellationToken cancellationToken)
{
if (ThrowOnAddNotification)
@@ -147,7 +148,7 @@ internal class FakeTwinCATClient : ITwinCATClient
await gate.Task.ConfigureAwait(false);
LastMaxDelayMs = maxDelayMs;
var reg = new FakeNotification(symbolPath, type, bitIndex, onChange, this);
var reg = new FakeNotification(symbolPath, type, bitIndex, onChange, this) { ArrayCount = arrayCount };
Notifications.Add(reg);
return reg;
}
@@ -195,6 +196,8 @@ internal class FakeTwinCATClient : ITwinCATClient
public TwinCATDataType Type { get; } = type;
/// <summary>Gets the optional bit index.</summary>
public int? BitIndex { get; } = bitIndex;
/// <summary>Gets the array count the driver registered with (null = scalar).</summary>
public int? ArrayCount { get; init; }
/// <summary>Gets the callback to invoke on value change.</summary>
public Action<string, object?> OnChange { get; } = onChange;
/// <summary>Gets a value indicating whether this notification has been disposed.</summary>
@@ -0,0 +1,268 @@
using System.Buffers.Binary;
using System.Text;
using Shouldly;
using TwinCAT.TypeSystem;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// Pins the array half of the raw-bytes model: <see cref="TwinCATTypeGuard.CheckArray"/> decides
/// whether an authored array tag (element type + <c>ArrayLength</c>) may decode a PLC array and
/// plans the byte layout, and <see cref="TwinCATArrayCodec.Decode"/> turns the PLC's raw bytes into
/// the element-typed CLR array both the poll read and the native notification publish.
/// </summary>
/// <remarks>
/// The raw-bytes model replaced the Beckhoff typed array read after the 2026-10-07 live gate on the
/// TwinCAT 2.11 Elma cleaner Z33856, where that read failed for most real arrays: the CLR element
/// type had to match exactly (<c>ARRAY OF TIME</c> as <c>uint[]</c> threw), a multi-dimensional
/// array demanded a rank-2 CLR array, and a dotted MEMBER array was sliced by the under-reported
/// symbol size (<c>P_INPUTVALUESFILTER.FBUSPWR1G1.SAMPLE</c>, <c>ARRAY [1..20] OF REAL</c>, symbol
/// 10 B vs type 80 B → <c>ArgumentOutOfRangeException</c>).
/// </remarks>
[Trait("Category", "Unit")]
public sealed class TwinCATArrayCodecTests
{
private static TwinCATPlcSymbolShape Elem(string typeName, int byteSize, DataTypeCategory? category = DataTypeCategory.Primitive)
=> TwinCATPlcSymbolShape.Classify(typeName, category, byteSize);
private static TwinCATPlcArrayShape Arr(TwinCATPlcSymbolShape element, params int[] dims)
=> new(dims, dims.Aggregate(1, (a, b) => a * b) * element.ByteSize, element);
private static TwinCATPlcSymbolShape ArraySymbol(string typeName, int byteSize)
=> TwinCATPlcSymbolShape.Classify(typeName, DataTypeCategory.Array, byteSize);
private static string? Check(TwinCATDataType declared, int declaredLength, TwinCATPlcArrayShape? array,
out TwinCATArrayLayout layout, string typeName = "ARRAY")
=> TwinCATTypeGuard.CheckArray(declared, declaredLength,
ArraySymbol(typeName, array?.ByteSize ?? 4), array, out layout);
// ---- guard: plan + validate ----
/// <summary>A matching element type and length plans the whole array at element-size stride.</summary>
[Fact]
public void CheckArray_plans_a_matching_numeric_array_at_its_element_stride()
{
Check(TwinCATDataType.UInt, 13, Arr(Elem("UINT", 2), 13), out var layout).ShouldBeNull();
layout.ShouldBe(new TwinCATArrayLayout(13, 2));
layout.ByteLength.ShouldBe(26);
}
/// <summary>
/// A multi-dimensional array (<c>ARRAY [1..3, 1..2] OF REAL</c>, live on Z33856) is published
/// flattened in PLC memory order; its ArrayLength is the TOTAL element count. Lower bounds play
/// no part — the published index is 0-based.
/// </summary>
[Fact]
public void CheckArray_flattens_a_multi_dimensional_array_to_its_total_count()
{
Check(TwinCATDataType.Real, 6, Arr(Elem("REAL", 4), 3, 2), out var layout).ShouldBeNull();
layout.ShouldBe(new TwinCATArrayLayout(6, 4));
}
/// <summary>
/// An authored ArrayLength SHORTER than the PLC array reads the leading sub-range only: the first
/// N elements in PLC memory order, at N × stride bytes from the array start — cheap (a smaller
/// notification) and well-defined, and it matches the node's advertised ArrayDimensions.
/// </summary>
[Fact]
public void CheckArray_plans_a_leading_sub_range_when_the_authored_length_is_shorter()
{
Check(TwinCATDataType.Real, 5, Arr(Elem("REAL", 4), 20), out var layout).ShouldBeNull();
layout.ShouldBe(new TwinCATArrayLayout(5, 4));
layout.ByteLength.ShouldBe(20);
}
/// <summary>An authored ArrayLength LONGER than the PLC array would read past it — refused.</summary>
[Fact]
public void CheckArray_refuses_an_authored_length_longer_than_the_plc_array()
=> Check(TwinCATDataType.UInt, 14, Arr(Elem("UINT", 2), 13), out _)!.ShouldContain("13");
/// <summary>A zero or negative authored length is not an array.</summary>
[Fact]
public void CheckArray_refuses_a_non_positive_authored_length()
=> Check(TwinCATDataType.UInt, 0, Arr(Elem("UINT", 2), 13), out _).ShouldNotBeNull();
/// <summary>
/// The element is checked with the same rules as a scalar notification (TwinCAT-002/-008): width,
/// float/integer kind and string kind must agree; a same-width, same-kind divergence is tolerated.
/// </summary>
[Fact]
public void CheckArray_checks_the_element_like_a_scalar_notification()
{
Check(TwinCATDataType.DInt, 4, Arr(Elem("REAL", 4), 4), out _).ShouldNotBeNull(); // float kind
Check(TwinCATDataType.Int, 4, Arr(Elem("DINT", 4), 4), out _).ShouldNotBeNull(); // width
Check(TwinCATDataType.DInt, 4, Arr(Elem("STRING(80)", 81, DataTypeCategory.String), 4), out _).ShouldNotBeNull();
Check(TwinCATDataType.String, 4, Arr(Elem("INT", 2), 4), out _).ShouldNotBeNull();
Check(TwinCATDataType.WString, 4, Arr(Elem("STRING(80)", 81, DataTypeCategory.String), 4), out _).ShouldNotBeNull();
Check(TwinCATDataType.Int, 4, Arr(Elem("UINT", 2), 4), out _).ShouldBeNull(); // same width, same kind
Check(TwinCATDataType.Time, 13, Arr(Elem("TIME", 4), 13), out _).ShouldBeNull();
Check(TwinCATDataType.USInt, 6, Arr(Elem("USINT", 1), 6), out _).ShouldBeNull();
Check(TwinCATDataType.SInt, 13, Arr(Elem("BYTE", 1), 13), out _).ShouldBeNull();
Check(TwinCATDataType.Bool, 19, Arr(Elem("BOOL", 1), 19), out _).ShouldBeNull();
Check(TwinCATDataType.LReal, 32, Arr(Elem("LREAL", 8), 32), out _).ShouldBeNull();
}
/// <summary>A STRING(n) / WSTRING(n) element is laid out at the PLC's own width: n+1 / 2(n+1).</summary>
[Fact]
public void CheckArray_strides_a_string_array_by_the_plc_element_width()
{
Check(TwinCATDataType.String, 10, Arr(Elem("STRING(80)", 81, DataTypeCategory.String), 10), out var s).ShouldBeNull();
s.ShouldBe(new TwinCATArrayLayout(10, 81));
Check(TwinCATDataType.WString, 3, Arr(Elem("WSTRING(20)", 42, DataTypeCategory.String), 3), out var w).ShouldBeNull();
w.ShouldBe(new TwinCATArrayLayout(3, 42));
}
/// <summary>
/// Arrays of structs / UDTs / FBs, of pointers, and arrays of arrays (jagged) have no scalar element
/// the driver can decode — refused with a reason that says so.
/// </summary>
[Fact]
public void CheckArray_refuses_struct_pointer_and_nested_array_elements()
{
Check(TwinCATDataType.DInt, 13, Arr(Elem("ST_CARRIERDATA", 120, DataTypeCategory.Struct), 13), out _).ShouldNotBeNull();
Check(TwinCATDataType.DInt, 4, Arr(Elem("POINTER TO BYTE", 4, DataTypeCategory.Pointer), 4), out _).ShouldNotBeNull();
Check(TwinCATDataType.Int, 2, Arr(Elem("ARRAY [0..2] OF INT", 6, DataTypeCategory.Array), 2), out _).ShouldNotBeNull();
}
/// <summary>
/// An array tag over a PLC symbol that is not an array (a scalar, a string, an element path such
/// as <c>Arr[3]</c>) is refused: there is no array to read.
/// </summary>
[Fact]
public void CheckArray_refuses_a_plc_symbol_that_is_not_an_array()
{
TwinCATTypeGuard.CheckArray(TwinCATDataType.DInt, 4, Elem("DINT", 4), null, out _).ShouldNotBeNull();
TwinCATTypeGuard.CheckArray(TwinCATDataType.String, 4,
Elem("STRING(80)", 81, DataTypeCategory.String), null, out _).ShouldNotBeNull();
}
/// <summary>A <c>VAR_IN_OUT</c> array member is a reference, not the array — refused.</summary>
[Fact]
public void CheckArray_refuses_a_var_in_out_reference()
{
var symbol = TwinCATPlcSymbolShape.Classify("ARRAY [1..3] OF INT", DataTypeCategory.Array, 4,
dataTypeByteSize: 6, symbolTypeName: "ARRAY [1..3] OF INT (VAR_IN_OUT)");
TwinCATTypeGuard.CheckArray(TwinCATDataType.Int, 3, symbol, Arr(Elem("INT", 2), 3), out _).ShouldNotBeNull();
}
/// <summary>
/// A layout whose element count × element size disagrees with the array's own byte size is not
/// a packed array the driver understands (padding, a mis-resolved element) — refused rather than
/// decoded at the wrong offsets.
/// </summary>
[Fact]
public void CheckArray_refuses_an_array_whose_size_is_not_count_times_element_size()
{
var padded = new TwinCATPlcArrayShape([4], 32, Elem("INT", 2));
Check(TwinCATDataType.Int, 4, padded, out _).ShouldNotBeNull();
}
// ---- guard: notification size ----
/// <summary>
/// A native notification is capped at <see cref="TwinCATTypeGuard.MaxNotificationArrayBytes"/>
/// (128 KiB — the largest registration proven on a real target: a TC 2.11
/// <c>ARRAY [0..65535] OF UINT</c>). Above it the tag is refused with a reason pointing at polling.
/// </summary>
[Fact]
public void CheckNotificationSize_caps_a_native_array_at_128_KiB()
{
TwinCATTypeGuard.MaxNotificationArrayBytes.ShouldBe(128 * 1024);
TwinCATTypeGuard.CheckNotificationSize(new TwinCATArrayLayout(65536, 2)).ShouldBeNull();
TwinCATTypeGuard.CheckNotificationSize(new TwinCATArrayLayout(65537, 2))!.ShouldContain("UseNativeNotifications");
}
// ---- codec: decode ----
/// <summary>Each numeric element type decodes little-endian into its CLR array, in PLC memory order.</summary>
[Fact]
public void Decode_numeric_elements_little_endian_into_the_declared_clr_array()
{
Decode<short>(TwinCATDataType.Int, 2, [-1, 300], (b, v) => BinaryPrimitives.WriteInt16LittleEndian(b, v));
Decode<ushort>(TwinCATDataType.UInt, 2, [65535, 7], (b, v) => BinaryPrimitives.WriteUInt16LittleEndian(b, v));
Decode<int>(TwinCATDataType.DInt, 4, [-123456, 42], (b, v) => BinaryPrimitives.WriteInt32LittleEndian(b, v));
Decode<uint>(TwinCATDataType.UDInt, 4, [4_000_000_000, 1], (b, v) => BinaryPrimitives.WriteUInt32LittleEndian(b, v));
Decode<long>(TwinCATDataType.LInt, 8, [long.MinValue, 9], (b, v) => BinaryPrimitives.WriteInt64LittleEndian(b, v));
Decode<ulong>(TwinCATDataType.ULInt, 8, [ulong.MaxValue, 3], (b, v) => BinaryPrimitives.WriteUInt64LittleEndian(b, v));
Decode<float>(TwinCATDataType.Real, 4, [949.2f, -0.5f], (b, v) => BinaryPrimitives.WriteSingleLittleEndian(b, v));
Decode<double>(TwinCATDataType.LReal, 8, [Math.PI, -1e300], (b, v) => BinaryPrimitives.WriteDoubleLittleEndian(b, v));
Decode<sbyte>(TwinCATDataType.SInt, 1, [-128, 127], (b, v) => b[0] = unchecked((byte)v));
Decode<byte>(TwinCATDataType.USInt, 1, [0, 255], (b, v) => b[0] = v);
}
/// <summary>
/// TIME / DATE / DT / TOD elements publish as their raw 32-bit count (<c>uint[]</c>) — the same
/// CLR shape a scalar of those types publishes. Live: <c>.THROTTLE_MODE_DEFAULT</c>
/// (<c>ARRAY [0..12] OF TIME</c>) begins <c>0, 10, 20</c> ms.
/// </summary>
[Theory]
[InlineData(TwinCATDataType.Time)]
[InlineData(TwinCATDataType.Date)]
[InlineData(TwinCATDataType.DateTime)]
[InlineData(TwinCATDataType.TimeOfDay)]
public void Decode_time_and_date_elements_as_uint(TwinCATDataType type)
=> TwinCATArrayCodec.Decode(type, [0, 0, 0, 0, 10, 0, 0, 0, 20, 0, 0, 0], new TwinCATArrayLayout(3, 4), utf8: false)
.ShouldBe(new uint[] { 0, 10, 20 });
/// <summary>A BOOL element is one byte; any non-zero byte is TRUE (live: <c>.ARGTYPE_IS_BIT_TYPE</c>).</summary>
[Fact]
public void Decode_bool_elements_one_byte_each_non_zero_is_true()
=> TwinCATArrayCodec.Decode(TwinCATDataType.Bool, [0, 1, 1, 0xFF, 0], new TwinCATArrayLayout(5, 1), utf8: false)
.ShouldBe(new[] { false, true, true, true, false });
/// <summary>
/// Each STRING(n) element sits at a fixed n+1 stride and ends at ITS first NUL — the stale tail a
/// longer earlier value left (live on Z33856) never leaks into the element, nor into the next one.
/// Windows-1252 unless the target reports UTF-8, exactly like a scalar string.
/// </summary>
[Fact]
public void Decode_string_elements_at_their_stride_each_ending_at_its_first_nul()
{
var bytes = new byte[3 * 6]; // STRING(5)
"RUN"u8.CopyTo(bytes.AsSpan(0));
bytes[4] = (byte)'T'; // stale tail after the NUL
"MON_O"u8.CopyTo(bytes.AsSpan(6)); // full-length, no NUL before the stride end
bytes[12] = 0xB0; // '°' in Windows-1252
TwinCATArrayCodec.Decode(TwinCATDataType.String, bytes, new TwinCATArrayLayout(3, 6), utf8: false)
.ShouldBe(new[] { "RUN", "MON_O", "°" });
var utf8 = new byte[2 * 4];
"°C"u8.CopyTo(utf8.AsSpan(0));
TwinCATArrayCodec.Decode(TwinCATDataType.String, utf8, new TwinCATArrayLayout(2, 4), utf8: true)
.ShouldBe(new[] { "°C", "" });
}
/// <summary>Each WSTRING(n) element is UTF-16LE at a 2(n+1) stride, ending at its first aligned U+0000.</summary>
[Fact]
public void Decode_wstring_elements_at_their_stride()
{
var bytes = new byte[2 * 8]; // WSTRING(3)
Encoding.Unicode.GetBytes("Ab").CopyTo(bytes, 0);
Encoding.Unicode.GetBytes("Zürn").CopyTo(bytes, 8); // 4 chars fill the whole stride
TwinCATArrayCodec.Decode(TwinCATDataType.WString, bytes, new TwinCATArrayLayout(2, 8), utf8: false)
.ShouldBe(new[] { "Ab", "Zürn" });
}
/// <summary>A sub-range layout decodes only its leading elements, even from a longer buffer.</summary>
[Fact]
public void Decode_honours_the_layout_count_not_the_buffer_length()
=> TwinCATArrayCodec.Decode(TwinCATDataType.Int, [1, 0, 2, 0, 3, 0], new TwinCATArrayLayout(2, 2), utf8: false)
.ShouldBe(new short[] { 1, 2 });
/// <summary>A buffer shorter than the layout is a protocol fault, never a silently-short array.</summary>
[Fact]
public void Decode_refuses_a_buffer_shorter_than_the_layout()
=> Should.Throw<ArgumentException>(() =>
TwinCATArrayCodec.Decode(TwinCATDataType.Int, [1, 0, 2], new TwinCATArrayLayout(2, 2), utf8: false));
private static void Decode<T>(TwinCATDataType type, int stride, T[] values, Action<Span<byte>, T> write)
{
var bytes = new byte[values.Length * stride];
for (var i = 0; i < values.Length; i++) write(bytes.AsSpan(i * stride, stride), values[i]);
var decoded = TwinCATArrayCodec.Decode(type, bytes, new TwinCATArrayLayout(values.Length, stride), utf8: false);
decoded.ShouldBeOfType<T[]>().ShouldBe(values);
}
}
@@ -0,0 +1,58 @@
using Shouldly;
using TwinCAT.TypeSystem;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// Pins <see cref="TwinCATBitAccess"/> — how a bit-indexed BOOL tag (<c>.ADSSTATE_RUN.0</c>) is served by a
/// native notification. ADS has no symbol for <c>WORD.N</c>: live on the TwinCAT 2.11 Elma cleaner Z33856
/// (2026-10-07) registering the notification on <c>.ADSSTATE_RUN.0</c> failed with
/// <c>DeviceSymbolNotFound</c> — and that registration error tore down the WHOLE subscribe call. The
/// notification now registers the PARENT word as raw bytes (its resolved width) and extracts the bit.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATBitAccessTests
{
/// <summary>The parent path is the bit path without its trailing <c>.N</c>; a non-bit path is unchanged.</summary>
[Fact]
public void ParentPath_strips_the_trailing_bit_index()
{
TwinCATBitAccess.ParentPath(".ADSSTATE_RUN.0").ShouldBe(".ADSSTATE_RUN");
TwinCATBitAccess.ParentPath("MAIN.Status.Flags.15").ShouldBe("MAIN.Status.Flags");
TwinCATBitAccess.ParentPath("MAIN.Flags").ShouldBe("MAIN.Flags");
}
/// <summary>Bits are numbered little-endian across the parent's bytes (bit 9 = byte 1, bit 1).</summary>
[Fact]
public void ReadBit_numbers_bits_little_endian_across_the_parent_bytes()
{
byte[] word = [0b0000_0101, 0b0000_0010]; // .ADSSTATE_RUN = 5 → bits 0 and 2; plus bit 9
TwinCATBitAccess.ReadBit(word, 0).ShouldBeTrue();
TwinCATBitAccess.ReadBit(word, 1).ShouldBeFalse();
TwinCATBitAccess.ReadBit(word, 2).ShouldBeTrue();
TwinCATBitAccess.ReadBit(word, 9).ShouldBeTrue();
TwinCATBitAccess.ReadBit(word, 15).ShouldBeFalse();
}
/// <summary>A bit past the parent's width cannot be read — refused instead of publishing FALSE at Good.</summary>
[Fact]
public void CheckParent_refuses_a_bit_outside_the_parent_width()
{
var word = TwinCATPlcSymbolShape.Classify("UINT16", DataTypeCategory.Primitive, 2);
TwinCATBitAccess.CheckParent(15, word).ShouldBeNull();
TwinCATBitAccess.CheckParent(16, word).ShouldNotBeNull();
TwinCATBitAccess.CheckParent(-1, word).ShouldNotBeNull();
}
/// <summary>Only an integer word has addressable bits — a string, a float or a container is refused.</summary>
[Fact]
public void CheckParent_refuses_a_parent_that_is_not_an_integer_word()
{
TwinCATBitAccess.CheckParent(0, TwinCATPlcSymbolShape.Classify("STRING(80)", DataTypeCategory.String, 81)).ShouldNotBeNull();
TwinCATBitAccess.CheckParent(0, TwinCATPlcSymbolShape.Classify("REAL", DataTypeCategory.Primitive, 4)).ShouldNotBeNull();
TwinCATBitAccess.CheckParent(0, TwinCATPlcSymbolShape.Classify("ST_X", DataTypeCategory.Struct, 8)).ShouldNotBeNull();
TwinCATBitAccess.CheckParent(31, TwinCATPlcSymbolShape.Classify("DWORD", DataTypeCategory.Primitive, 4)).ShouldBeNull();
}
}
@@ -0,0 +1,137 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// Native ADS notifications for ARRAY tags. Before this, the notification path registered only the
/// declared scalar, so an array tag published its first element at Good; the declared-type guard
/// then refused every array tag with <c>BadTypeMismatch</c>. The driver now hands the authored
/// <c>ArrayLength</c> to the client, which registers the whole array as raw bytes and decodes it
/// (<see cref="TwinCATArrayCodec"/>) — these pin the driver's half of that contract.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATNativeArraySubscribeTests
{
private const string Host = "ads://5.23.91.23.1.1:851";
private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewNativeDriver(params TwinCATTagDefinition[] tags)
{
var factory = new FakeTwinCATClientFactory();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(Host)],
RawTags = TwinCATRawTags.Entries(tags),
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
}, "drv-1", factory);
return (drv, factory);
}
/// <summary>
/// An array tag registers with its authored ArrayLength, and the element-typed array the client
/// decodes reaches OnDataChange whole, at Good — the same shape the poll read publishes.
/// </summary>
[Fact]
public async Task Native_subscribe_registers_an_array_tag_with_its_authored_length_and_publishes_the_array()
{
var (drv, factory) = NewNativeDriver(
new TwinCATTagDefinition("Samples", Host, "P_FILTER.SAMPLE", TwinCATDataType.Real, ArrayLength: 20));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["Samples"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
var reg = factory.Clients[0].Notifications.ShouldHaveSingleItem();
reg.Type.ShouldBe(TwinCATDataType.Real);
reg.ArrayCount.ShouldBe(20);
var samples = Enumerable.Range(1, 20).Select(i => i * 1.5f).ToArray();
factory.Clients[0].FireNotification("P_FILTER.SAMPLE", samples);
var e = events.ShouldHaveSingleItem();
e.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
e.Snapshot.Value.ShouldBeSameAs(samples);
}
/// <summary>
/// A scalar tag, an element-indexed path (<c>Arr[3]</c>) and a bit-indexed BOOL stay scalars: no
/// array count reaches the client.
/// </summary>
[Fact]
public async Task Scalar_element_indexed_and_bit_indexed_tags_register_without_an_array_count()
{
var (drv, factory) = NewNativeDriver(
new TwinCATTagDefinition("Count", Host, "MAIN.Count", TwinCATDataType.DInt),
new TwinCATTagDefinition("Fault3", Host, "MAIN.FaultCodes[3]", TwinCATDataType.UInt),
new TwinCATTagDefinition("Run0", Host, "MAIN.State.0", TwinCATDataType.Bool));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.SubscribeAsync(["Count", "Fault3", "Run0"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
var regs = factory.Clients[0].Notifications;
regs.Count.ShouldBe(3);
regs.ShouldAllBe(n => n.ArrayCount == null);
regs.Single(n => n.SymbolPath == "MAIN.State.0").BitIndex.ShouldBe(0);
}
/// <summary>A reconnect replays an array registration with the same array count.</summary>
[Fact]
public async Task Reconnect_replays_an_array_registration_with_its_array_count()
{
var (drv, factory) = NewNativeDriver(
new TwinCATTagDefinition("Faults", Host, "MAIN.FaultCodes", TwinCATDataType.UInt, ArrayLength: 13));
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.SubscribeAsync(["Faults"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
factory.Clients[0].SimulateWireDrop();
_ = await drv.ReadAsync(["Faults"], TestContext.Current.CancellationToken);
factory.Clients.Count.ShouldBe(2);
factory.Clients[1].Notifications.ShouldHaveSingleItem().ArrayCount.ShouldBe(13);
}
/// <summary>
/// A notification the client refuses for a reason other than a type mismatch (an array above the
/// native-notification size cap → <c>BadOutOfRange</c>) is isolated the same way: that one tag
/// publishes the refusal's own status and the rest of the subscription registers.
/// </summary>
[Fact]
public async Task A_refused_oversized_array_is_isolated_with_the_refusals_own_status()
{
var (drv, factory) = NewNativeDriver(
new TwinCATTagDefinition("Slaves", Host, "P_EC.SLAVEADDRESS", TwinCATDataType.UInt, ArrayLength: 70000),
new TwinCATTagDefinition("Count", Host, "MAIN.Count", TwinCATDataType.DInt));
factory.Customise = () =>
{
var c = new FakeTwinCATClient();
c.AddNotificationErrors["P_EC.SLAVEADDRESS"] = new TwinCATNotificationRefusedException(
"P_EC.SLAVEADDRESS", "140000 B exceeds the 131072 B native-notification cap",
TwinCATStatusMapper.BadOutOfRange);
return c;
};
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["Slaves", "Count"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
factory.Clients[0].Notifications.ShouldHaveSingleItem().SymbolPath.ShouldBe("MAIN.Count");
var refused = events.ShouldHaveSingleItem();
refused.FullReference.ShouldBe("Slaves");
refused.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
refused.Snapshot.Value.ShouldBeNull();
}
/// <summary>A type-mismatch refusal is a notification refusal carrying <c>BadTypeMismatch</c>.</summary>
[Fact]
public void A_type_mismatch_is_a_notification_refusal_carrying_BadTypeMismatch()
{
TwinCATNotificationRefusedException ex = new TwinCATTypeMismatchException("MAIN.X", "declared DInt over REAL");
ex.StatusCode.ShouldBe(TwinCATStatusMapper.BadTypeMismatch);
ex.SymbolPath.ShouldBe("MAIN.X");
}
}
@@ -191,13 +191,13 @@ public sealed class TwinCATNativeNotificationTests
/// <inheritdoc />
public override Task<ITwinCATNotificationHandle> AddNotificationAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime,
string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, TimeSpan cycleTime,
int maxDelayMs, Action<string, object?> onChange, CancellationToken cancellationToken)
{
AddCallCount++;
if (AddCallCount > _succeedBefore)
throw new InvalidOperationException($"fake fail on call #{AddCallCount}");
return base.AddNotificationAsync(symbolPath, type, bitIndex, cycleTime, maxDelayMs, onChange, cancellationToken);
return base.AddNotificationAsync(symbolPath, type, bitIndex, arrayCount, cycleTime, maxDelayMs, onChange, cancellationToken);
}
}
@@ -105,6 +105,26 @@ public sealed class TwinCATTypeGuardTests
TwinCATTypeGuard.CheckRead(TwinCATDataType.String, Plc("ST_LOGDATA", 81, DataTypeCategory.Struct)).ShouldNotBeNull();
}
/// <summary>
/// A SCALAR tag over a PLC array has no value the symbol-aware read can convert: live on Z33856
/// (2026-10-07) <c>.ARRFAULTCODE</c> (<c>ARRAY [0..12] OF UINT</c>) read as <c>UInt</c> made the Beckhoff
/// read throw, surfacing as <c>BadCommunicationError</c> and a Degraded driver, while the native path
/// said <c>BadTypeMismatch</c>. Both paths now refuse it with <c>BadTypeMismatch</c> — by category, so an
/// alias of an array type is caught too. Other non-scalars (a pointer read as its address) are left
/// to the read, as before.
/// </summary>
[Fact]
public void Read_refuses_a_scalar_declared_over_a_plc_array()
{
TwinCATTypeGuard.CheckRead(TwinCATDataType.UInt, Plc("ARRAY [0..12] OF UINT", 26, DataTypeCategory.Array))
.ShouldNotBeNull();
TwinCATTypeGuard.CheckRead(TwinCATDataType.DInt, Plc("T_FAULTS", 52, DataTypeCategory.Array)).ShouldNotBeNull();
TwinCATTypeGuard.CheckRead(TwinCATDataType.UDInt, Plc("POINTER TO BYTE", 4, DataTypeCategory.Pointer)).ShouldBeNull();
Plc("ARRAY [0..12] OF UINT", 26, DataTypeCategory.Array).IsArray.ShouldBeTrue();
Plc("ARRAY [0..12] OF UINT", 26).IsArray.ShouldBeTrue(); // by name when no category resolved
Plc("UINT", 2, DataTypeCategory.Primitive).IsArray.ShouldBeFalse();
}
/// <summary>
/// 0.19.2: a STRING / WSTRING tag matches a PLC <c>STRING(n)</c> / <c>WSTRING(n)</c> of ANY length.
/// A numeric-over-numeric pair is left to the symbol-aware read, which converts the value.
@@ -108,7 +108,8 @@ public sealed class AbCipDriverPageFormSerializationTests
[Theory]
[InlineData(AbCipTransport.Libplctag)]
[InlineData(AbCipTransport.Native)]
public void Transport_survives_FromOptions_ToOptions(AbCipTransport transport)
[InlineData(null)]
public void Transport_survives_FromOptions_ToOptions(AbCipTransport? transport)
{
var original = new AbCipDriverOptions { Transport = transport };
var back = AbCipDriverForm.FormModel.FromOptions(original).ToOptions([]);
@@ -123,35 +124,52 @@ public sealed class AbCipDriverPageFormSerializationTests
json.ShouldContain("\"transport\":\"Native\"");
}
[Fact]
public void Editing_an_unrelated_field_preserves_a_saved_Native_transport()
[Theory]
[InlineData("Native", AbCipTransport.Native)]
[InlineData("Libplctag", AbCipTransport.Libplctag)]
public void Editing_an_unrelated_field_preserves_an_explicit_transport(string savedValue, AbCipTransport expected)
{
// The regression the review found: FromOptions/ToOptions dropped Transport, so an operator who set
// Native was silently reverted to libplctag the next time they saved ANY other edit.
const string saved = """{"transport":"Native","timeout":"00:00:02","devices":[]}""";
// a transport was silently reverted the next time they saved ANY other edit.
var saved = $$"""{"transport":"{{savedValue}}","timeout":"00:00:02","devices":[]}""";
var loaded = AbCipDriverForm.TryDeserialize(saved);
loaded.ShouldNotBeNull();
loaded.Transport.ShouldBe(AbCipTransport.Native);
loaded.Transport.ShouldBe(expected);
var form = AbCipDriverForm.FormModel.FromOptions(loaded);
form.TimeoutSeconds = 7;
var json = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices));
json.ShouldContain("\"transport\":\"Native\"");
json.ShouldContain($"\"transport\":\"{savedValue}\"");
json.ShouldContain("\"timeout\":\"00:00:07\"");
}
[Fact]
public void A_config_without_transport_loads_as_Libplctag_and_re_emits_it_explicitly()
public void A_config_without_transport_loads_as_Default_and_an_unrelated_edit_keeps_the_key_absent()
{
// Chosen behaviour: an absent key round-trips as an explicit "Libplctag" (the form always writes the
// field). The dropdown therefore shows "libplctag (default)".
// Task 22 reverses Task 19's "re-emit an explicit Libplctag": that would pin every edited driver to
// a concrete transport and bypass the driver-side default (and AbLegacy's PLC-5 / LogixPccc
// exclusion). Absent loads as Default (null) and a save that edits something else still omits it.
var loaded = AbCipDriverForm.TryDeserialize("""{"probeTimeoutSeconds":5}""");
loaded.ShouldNotBeNull();
var form = AbCipDriverForm.FormModel.FromOptions(loaded);
form.Transport.ShouldBeNull();
form.TimeoutSeconds = 7;
var json = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices));
json.ShouldNotContain("transport", Case.Insensitive);
json.ShouldContain("\"timeout\":\"00:00:07\"");
}
[Fact]
public void Choosing_Default_over_an_explicit_transport_drops_the_key()
{
var loaded = AbCipDriverForm.TryDeserialize("""{"transport":"Libplctag"}""");
loaded.ShouldNotBeNull();
var form = AbCipDriverForm.FormModel.FromOptions(loaded);
form.Transport.ShouldBe(AbCipTransport.Libplctag);
var json = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices));
json.ShouldContain("\"transport\":\"Libplctag\"");
form.Transport = null; // the "Default (...)" dropdown option
AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices)).ShouldNotContain("transport", Case.Insensitive);
}
}
@@ -104,7 +104,8 @@ public sealed class AbLegacyDriverPageFormSerializationTests
[Theory]
[InlineData(AbLegacyTransport.Libplctag)]
[InlineData(AbLegacyTransport.Native)]
public void Transport_survives_FromOptions_ToOptions(AbLegacyTransport transport)
[InlineData(null)]
public void Transport_survives_FromOptions_ToOptions(AbLegacyTransport? transport)
{
var original = new AbLegacyDriverOptions { Transport = transport };
var back = AbLegacyDriverForm.FormModel.FromOptions(original).ToOptions([]);
@@ -119,35 +120,71 @@ public sealed class AbLegacyDriverPageFormSerializationTests
json.ShouldContain("\"transport\":\"Native\"");
}
[Fact]
public void Editing_an_unrelated_field_preserves_a_saved_Native_transport()
[Theory]
[InlineData("Native", AbLegacyTransport.Native)]
[InlineData("Libplctag", AbLegacyTransport.Libplctag)]
public void Editing_an_unrelated_field_preserves_an_explicit_transport(string savedValue, AbLegacyTransport expected)
{
// The regression the review found: FromOptions/ToOptions dropped Transport, so an operator who set
// Native was silently reverted to libplctag the next time they saved ANY other edit.
const string saved = """{"transport":"Native","timeout":"00:00:02","devices":[]}""";
// a transport was silently reverted the next time they saved ANY other edit.
var saved = $$"""{"transport":"{{savedValue}}","timeout":"00:00:02","devices":[]}""";
var loaded = AbLegacyDriverForm.TryDeserialize(saved);
loaded.ShouldNotBeNull();
loaded.Transport.ShouldBe(AbLegacyTransport.Native);
loaded.Transport.ShouldBe(expected);
var form = AbLegacyDriverForm.FormModel.FromOptions(loaded);
form.TimeoutSeconds = 7;
var json = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices));
json.ShouldContain("\"transport\":\"Native\"");
json.ShouldContain($"\"transport\":\"{savedValue}\"");
json.ShouldContain("\"timeout\":\"00:00:07\"");
}
[Fact]
public void A_config_without_transport_loads_as_Libplctag_and_re_emits_it_explicitly()
public void A_config_without_transport_loads_as_Default_and_an_unrelated_edit_keeps_the_key_absent()
{
// Chosen behaviour: an absent key round-trips as an explicit "Libplctag" (the form always writes the
// field). The dropdown therefore shows "libplctag (default)".
// Task 22 reverses Task 19's "re-emit an explicit Libplctag": that would pin every edited driver to
// a concrete transport and bypass the driver-side default (Libplctag since final review I3). Absent loads as Default (null) and a save that edits something else still omits it.
var loaded = AbLegacyDriverForm.TryDeserialize("""{"probeTimeoutSeconds":5}""");
loaded.ShouldNotBeNull();
var form = AbLegacyDriverForm.FormModel.FromOptions(loaded);
form.Transport.ShouldBeNull();
form.TimeoutSeconds = 7;
var json = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices));
json.ShouldNotContain("transport", Case.Insensitive);
json.ShouldContain("\"timeout\":\"00:00:07\"");
}
[Fact]
public void Choosing_Default_over_an_explicit_transport_drops_the_key()
{
var loaded = AbLegacyDriverForm.TryDeserialize("""{"transport":"Libplctag"}""");
loaded.ShouldNotBeNull();
var form = AbLegacyDriverForm.FormModel.FromOptions(loaded);
form.Transport.ShouldBe(AbLegacyTransport.Libplctag);
var json = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices));
json.ShouldContain("\"transport\":\"Libplctag\"");
form.Transport = null; // the "Default (...)" dropdown option
AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices)).ShouldNotContain("transport", Case.Insensitive);
}
/// <summary>
/// Final review I3: the dropdown labels must say what an absent key does (libplctag) and that Native is
/// opt-in. Source scan (no bUnit in this repo); the labels are static option text.
/// </summary>
[Fact]
public void The_transport_dropdown_labels_name_libplctag_as_the_default_and_native_as_opt_in()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
dir = dir.Parent;
dir.ShouldNotBeNull("could not locate the repository root");
var razor = File.ReadAllText(Path.Combine(dir!.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.AdminUI",
"Components", "Shared", "Drivers", "Forms", "AbLegacyDriverForm.razor"));
razor.ShouldContain("<option value=\"\">Default (libplctag)</option>");
razor.ShouldContain("<option value=\"@AbLegacyTransport.Native\">Native (managed, opt-in)</option>");
razor.ShouldContain("<option value=\"@AbLegacyTransport.Libplctag\">libplctag</option>");
razor.ShouldNotContain("Default (Native");
}
}
@@ -168,8 +168,10 @@ public sealed class DriverProbeHandshakeE2eTests
// ---- AbCip : libplctag CIP session handshake (skips unless the sim fixture is up) ----
[Fact]
public async Task AbCip_Green_AgainstSim()
[Theory]
[InlineData("Native")]
[InlineData("Libplctag")]
public async Task AbCip_Green_AgainstSim(string transport)
{
SkipUnless(DockerHost, AbCipPort);
// Probe an explicit tag that the ab_server ControlLogix sim actually defines
@@ -182,7 +184,9 @@ public sealed class DriverProbeHandshakeE2eTests
// exercising the one path the comment above says it does not exercise — and reporting it as a
// failure of the fixture rather than of the config shape.
var result = await new AbCipDriverProbe().ProbeAsync(
$"{{\"Devices\":[{{\"HostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}]," +
// Run on BOTH transports (Task 22 review r1 I3): Native is the production default, and the
// probe's green messages are transport-neutral strings, so each row asserts the same text.
$"{{\"Transport\":\"{transport}\",\"Devices\":[{{\"HostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}]," +
$"\"RawTags\":[{{\"RawPath\":\"Plant/AbCip/dev1/TestDINT\"," +
$"\"TagConfig\":\"{{\\\"tagPath\\\":\\\"TestDINT\\\",\\\"dataType\\\":\\\"DInt\\\"}}\"," +
$"\"WriteIdempotent\":false,\"DeviceName\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}]}}",
@@ -212,12 +216,16 @@ public sealed class DriverProbeHandshakeE2eTests
/// <c>Docker/patches/0002-ab_server-answer-tag-not-found-with-cip-0x04.patch</c> — so this test
/// needs the fixture rebuilt from that patch, not merely running.
/// </remarks>
[Fact]
public async Task AbCip_WithNoAuthoredTags_ReportsTheControllerReachable()
[Theory]
[InlineData("Native")]
[InlineData("Libplctag")]
public async Task AbCip_WithNoAuthoredTags_ReportsTheControllerReachable(string transport)
{
SkipUnless(DockerHost, AbCipPort);
var result = await new AbCipDriverProbe().ProbeAsync(
$"{{\"Devices\":[{{\"HostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}],\"RawTags\":[]}}",
// Both transports (Task 22 review r1 I3): native maps CIP 0x04 to AbStatus.NotFound exactly as
// libplctag's ErrorNotFound does, so the reachable classification is the same.
$"{{\"Transport\":\"{transport}\",\"Devices\":[{{\"HostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}],\"RawTags\":[]}}",
Timeout, Ct);
result.Ok.ShouldBeTrue($"Probe message: {result.Message}");
@@ -0,0 +1,154 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
/// <summary>
/// Final review I1 — the node-level <c>Drivers:AbCip:DefaultTransport</c>: binding (appsettings and the
/// <c>__</c> env-var form), precedence (explicit per-driver key &gt; node default &gt; compiled Native), the
/// probe agreeing with the driver factory, and fail-fast validation. Every precedence test runs a pinned
/// node (Libplctag) against the compiled default (Native), so a wiring that ignores the node value goes red.
/// </summary>
[Trait("Category", "Unit")]
[Collection(AbTransportSharedPoolCollection.Name)] // resolves the registry, which configures the shared pool
public sealed class AbCipDriverDefaultsOptionsTests
{
private const string NoKey = """{"devices":[{"hostAddress":"ab://10.0.0.1/1,0"}],"probe":{"enabled":false}}""";
private const string ExplicitNative =
"""{"transport":"Native","devices":[{"hostAddress":"ab://10.0.0.1/1,0"}],"probe":{"enabled":false}}""";
private static ServiceProvider Build(IConfiguration configuration)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<ISecretResolver>(new StubSecretResolver());
services.AddValidatedOptions<AbCipDriverDefaultsOptions, AbCipDriverDefaultsOptionsValidator>(
configuration, AbCipDriverDefaultsOptions.SectionName);
services.AddOtOpcUaDriverFactories();
return services.BuildServiceProvider();
}
private static IConfiguration Config(string? value) =>
new ConfigurationBuilder()
.AddInMemoryCollection(value is null
? []
: new Dictionary<string, string?> { ["Drivers:AbCip:DefaultTransport"] = value })
.Build();
private static AbCipDriver Create(ServiceProvider sp, string json) =>
(AbCipDriver)sp.GetRequiredService<DriverFactoryRegistry>().TryGet(DriverTypeNames.AbCip)!("drv-i1", json);
[Fact]
public void Absent_section_defaults_to_Native()
{
using var sp = Build(Config(null));
sp.GetRequiredService<IOptions<AbCipDriverDefaultsOptions>>().Value.ResolveDefaultTransport()
.ShouldBe(AbCipTransport.Native);
Create(sp, NoKey).EffectiveTransport.ShouldBe(AbCipTransport.Native);
}
[Theory]
[InlineData("Libplctag", AbCipTransport.Libplctag)]
[InlineData("libplctag", AbCipTransport.Libplctag)]
[InlineData("NATIVE", AbCipTransport.Native)]
[InlineData("", AbCipTransport.Native)]
public void The_appsettings_key_binds(string value, AbCipTransport expected)
{
using var sp = Build(Config(value));
sp.GetRequiredService<IOptions<AbCipDriverDefaultsOptions>>().Value.ResolveDefaultTransport()
.ShouldBe(expected);
}
/// <summary>The documented env-var override form, <c>Drivers__AbCip__DefaultTransport</c>.</summary>
[Fact]
public void The_double_underscore_environment_variable_binds()
{
const string prefix = "OTOPCUA_I1_TEST_";
Environment.SetEnvironmentVariable(prefix + "Drivers__AbCip__DefaultTransport", "Libplctag");
try
{
var configuration = new ConfigurationBuilder().AddEnvironmentVariables(prefix).Build();
using var sp = Build(configuration);
Create(sp, NoKey).EffectiveTransport.ShouldBe(AbCipTransport.Libplctag);
}
finally
{
Environment.SetEnvironmentVariable(prefix + "Drivers__AbCip__DefaultTransport", null);
}
}
/// <summary>A pinned node moves no-key drivers; an explicit per-driver key still wins over the pin.</summary>
[Fact]
public void Node_default_applies_only_to_drivers_without_a_key()
{
using var sp = Build(Config("Libplctag"));
Create(sp, NoKey).EffectiveTransport.ShouldBe(AbCipTransport.Libplctag);
Create(sp, ExplicitNative).EffectiveTransport.ShouldBe(AbCipTransport.Native);
}
/// <summary>The Test Connect probe resolves an absent key to the SAME node default the driver does.</summary>
[Theory]
[InlineData("Libplctag", AbCipTransport.Libplctag)]
[InlineData(null, AbCipTransport.Native)]
public void The_probe_uses_the_node_default(string? value, AbCipTransport expected)
{
using var sp = Build(Config(value));
var probe = sp.GetServices<IDriverProbe>().OfType<AbCipDriverProbe>().ShouldHaveSingleItem();
probe.DefaultTransport.ShouldBe(expected);
Create(sp, NoKey).EffectiveTransport.ShouldBe(expected); // driver and probe agree
}
/// <summary>The probe stays registered exactly once on a fused node (driver path + admin path).</summary>
[Fact]
public void The_factory_built_probe_is_still_deduplicated()
{
var services = new ServiceCollection();
services.AddOtOpcUaDriverProbes();
services.AddOtOpcUaDriverProbes();
using var sp = services.BuildServiceProvider();
sp.GetServices<IDriverProbe>().OfType<AbCipDriverProbe>().Count().ShouldBe(1);
}
[Theory]
[InlineData("Bogus")]
[InlineData("1")]
[InlineData("7")]
[InlineData("Native,Libplctag")]
public void A_bad_value_fails_validation_with_a_named_message(string value)
{
var result = new AbCipDriverDefaultsOptionsValidator().Validate(
null, new AbCipDriverDefaultsOptions { DefaultTransport = value });
result.Failed.ShouldBeTrue();
result.Failures.ShouldContain(f => f.Contains("Drivers:AbCip:DefaultTransport") && f.Contains(value));
using var sp = Build(Config(value));
Should.Throw<OptionsValidationException>(
() => sp.GetRequiredService<IOptions<AbCipDriverDefaultsOptions>>().Value)
.Message.ShouldContain("Drivers:AbCip:DefaultTransport");
}
[Theory]
[InlineData("Native")]
[InlineData("libplctag")]
[InlineData(null)]
[InlineData(" ")]
public void A_good_or_blank_value_validates(string? value) =>
new AbCipDriverDefaultsOptionsValidator()
.Validate(null, new AbCipDriverDefaultsOptions { DefaultTransport = value })
.Succeeded.ShouldBeTrue();
private sealed class StubSecretResolver : ISecretResolver
{
public Task<string?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult<string?>(null);
}
}
@@ -0,0 +1,108 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
/// <summary>The shared pool is process-wide, so the tests that touch it must not run in parallel.</summary>
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class AbTransportSharedPoolCollection
{
public const string Name = "AbTransportSharedPool";
}
/// <summary>
/// Final review I2: <c>DeviceSessionPool.Shared</c> must log through the HOST logger factory, on driver
/// nodes (via the driver registry) and on admin-only nodes (via the AbCip / AbLegacy Test Connect probes).
/// Each test first resets the pool to a null logger and asserts that baseline, so deleting the wiring
/// call turns it red (the logger would stay the reset one).
/// </summary>
[Trait("Category", "Unit")]
[Collection(AbTransportSharedPoolCollection.Name)]
public sealed class AbTransportSharedLoggerWiringTests
{
private static RecordingLoggerFactory Reset()
{
DeviceSessionPool.ConfigureSharedLogger(NullLoggerFactory.Instance);
var factory = new RecordingLoggerFactory();
AssertPoolLogsTo(factory, expected: false); // baseline: the wiring under test has not run yet
return factory;
}
// Logger<T> wraps the factory's logger, so identity cannot be compared: emit through the pool's logger
// and check whether it lands in the recording factory under the pool's category.
private static void AssertPoolLogsTo(RecordingLoggerFactory factory, bool expected)
{
var marker = Guid.NewGuid().ToString("N");
DeviceSessionPool.Shared.Logger.LogWarning("wiring-probe {Marker}", marker);
factory.Messages(typeof(DeviceSessionPool).FullName!).Any(m => m.Contains(marker)).ShouldBe(expected);
}
[Fact]
public void Resolving_the_driver_registry_wires_the_pool_to_the_host_logger_factory()
{
var factory = Reset();
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory>(factory);
services.AddSingleton<ISecretResolver>(new StubSecretResolver());
services.AddOtOpcUaDriverFactories();
using var sp = services.BuildServiceProvider();
sp.GetRequiredService<DriverFactoryRegistry>();
AssertPoolLogsTo(factory, expected: true);
}
[Theory]
[InlineData(DriverTypeNames.AbCip)]
[InlineData(DriverTypeNames.AbLegacy)]
public void An_admin_only_node_wires_the_pool_through_the_AB_probes(string driverType)
{
var factory = Reset();
var services = new ServiceCollection();
services.AddSingleton<ILoggerFactory>(factory);
services.AddOtOpcUaDriverProbes(); // the admin-only path: no driver registry
using var sp = services.BuildServiceProvider();
sp.GetServices<IDriverProbe>().Single(p => p.DriverType == driverType);
AssertPoolLogsTo(factory, expected: true);
}
private sealed class RecordingLoggerFactory : ILoggerFactory
{
private readonly List<(string Category, string Message)> _messages = [];
public IReadOnlyList<string> Messages(string category)
{
lock (_messages) return [.. _messages.Where(m => m.Category == category).Select(m => m.Message)];
}
public ILogger CreateLogger(string categoryName) => new Recording(this, categoryName);
public void AddProvider(ILoggerProvider provider) { }
public void Dispose() { }
private sealed class Recording(RecordingLoggerFactory owner, string category) : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
lock (owner._messages) owner._messages.Add((category, formatter(state, exception)));
}
}
}
private sealed class StubSecretResolver : ISecretResolver
{
public Task<string?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult<string?>(null);
}
}