Compare commits

...

19 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 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
69 changed files with 3478 additions and 318 deletions
+27 -7
View File
@@ -976,13 +976,33 @@ Dev/test LDAP is the **shared GLAuth** running on the Linux Docker host at `10.1
## 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` = `Libplctag` (default) | `Native` (string; takes effect when the driver is
recreated by a deploy; AdminUI "Transport" dropdown; CLIs `--transport`). **Opt-in:** the default stays
`Libplctag` until the real-press read-only gate (Task 21) runs and the default is flipped. Native writes are
verified against `ab_server` only. Client-visible: native reports `BadTypeMismatch` where libplctag returned
Good garbage for a same-width mis-authored type (DINT authored, REAL returned). libplctag is confined to
`Libplctag*.cs` adapters by `LibplctagConfinementGuardTests`; do not reference it elsewhere. See
`docs/drivers/AbCip.md` §Transport, `docs/drivers/AbLegacy.md`, and
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
+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)
+71 -18
View File
@@ -76,7 +76,7 @@ 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`** — `Libplctag` (default) or `Native`; see [Transport](#transport-transport-option).
- **`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.
@@ -89,24 +89,69 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
| Key | Values | Default | Serialization |
|---|---|---|---|
| `transport` | `Libplctag` \| `Native` | `Libplctag` | string, case-insensitive on read, written as `"Libplctag"` / `"Native"` |
| `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 |
- **`Libplctag`** (default) drives the libplctag native library, as before.
- **`Native`** is a managed in-process EtherNet/IP + CIP client (`Driver.Ab.Transport`). It is **opt-in**:
the default stays `Libplctag` until the real-press read-only gate has run and the default is flipped.
- **`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 **recreated**. A deploy that changes `DriverConfig`
- 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 ("libplctag
(default)" / "Native (managed)"). Saving another field preserves it.
- The driver logs `transport=<X>` at Information on every init, including reconnect re-inits. AbCip
- 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 native|libplctag` (default `libplctag`).
- 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,
@@ -114,8 +159,12 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
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` are honoured per pooled connection, and the **first** driver/device to open
a given device key wins. ControlLogix, GuardLogix and (since T21) CompactLogix default to 4002, a Large
- `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.
@@ -128,24 +177,28 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
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.
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).
- Status-bit sub-element writes (e.g. `T4:0/DN`) are Unsupported natively (they would clobber the control
word); nothing goes on the wire. Sub-element arrays are Unsupported natively.
- **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) is **read-only** and **has not run yet**; this page makes no real-controller
claim for the native transport. Results land in
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 planned default flip excludes `logixpccc`.
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
+71 -17
View File
@@ -93,33 +93,85 @@ 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`** — `Libplctag` (default) or `Native`; see [Transport](#transport-transport-option).
- **`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` = `Libplctag` (default) \| `Native`, a string (case-insensitive on read,
written as `"Libplctag"` / `"Native"`). `Native` is a managed PCCC-over-EtherNet/IP client
(`Driver.Ab.Transport`) and is **opt-in**; the default stays `Libplctag` pending the real-press gate and the
default flip. A change takes effect when the driver instance is recreated (a deploy that changes
`DriverConfig`); the AdminUI form has a **Transport** dropdown in the advanced section, and the driver logs
`transport=<X>` on every init. CLI: `--transport native|libplctag`. See
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.** PCCC reads and writes are verified against `ab_server` only. No real SLC/MicroLogix/
PLC-5 has been exercised, and the real-press gate (Task 21) is read-only and has not run yet.
- **PLC-5 native is N-file reads only.** Every other file type and all PLC-5 writes are refused, currently
surfacing as `BadCommunicationError` rather than `BadNotSupported` (#571). The planned default flip excludes
PLC-5 and `logixpccc`.
- Status-bit sub-element writes (e.g. `T4:0/DN`, `C5:0/DN`) are Unsupported natively (they would clobber the
- ⚠️ **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.
@@ -138,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
@@ -153,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
+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.
@@ -223,7 +223,27 @@ There were **zero** 0x4D/0x53/0x4B. Both aborts (runs 1 and 5) were the simulato
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 `## Latency (T21)` and is not filled in here.
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)
@@ -342,7 +362,7 @@ Failures, and what they led to:
| 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**: the operator's `tcpdump` had stopped at 23:45 UTC, before pass 2 began,
⚠️ **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.
@@ -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
@@ -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 },
};
@@ -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();
@@ -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)";
}
@@ -31,7 +31,11 @@ internal static class LibplctagOrphanRelease
private const BindingFlags Private = BindingFlags.NonPublic | BindingFlags.Instance;
private static readonly FieldInfo? WrapperField = typeof(Tag).GetField("_tag", Private);
private static readonly FieldInfo? HandleField = WrapperField?.FieldType.GetField("nativeTagHandle", 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 = [];
@@ -50,9 +54,20 @@ internal static class LibplctagOrphanRelease
/// <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).</summary>
internal static int HandleOf(Tag tag) =>
HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper ? (int)HandleField.GetValue(wrapper)! : 0;
/// <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)
@@ -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);
}
@@ -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)";
}
@@ -31,7 +31,11 @@ internal static class LibplctagOrphanRelease
private const BindingFlags Private = BindingFlags.NonPublic | BindingFlags.Instance;
private static readonly FieldInfo? WrapperField = typeof(Tag).GetField("_tag", Private);
private static readonly FieldInfo? HandleField = WrapperField?.FieldType.GetField("nativeTagHandle", 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 = [];
@@ -50,9 +54,20 @@ internal static class LibplctagOrphanRelease
/// <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).</summary>
internal static int HandleOf(Tag tag) =>
HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper ? (int)HandleField.GetValue(wrapper)! : 0;
/// <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)
@@ -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;
}
}
@@ -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"));
}
}
@@ -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
}
@@ -36,6 +36,28 @@ public sealed class LibplctagOrphanReleaseTests
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()
{
@@ -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>
@@ -16,6 +16,28 @@ public sealed class LibplctagLegacyOrphanReleaseTests
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()
{
@@ -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);
}
}