Compare commits

..

14 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 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
57 changed files with 2558 additions and 316 deletions
+26 -11
View File
@@ -977,17 +977,32 @@ Dev/test LDAP is the **shared GLAuth** running on the Linux Docker host at `10.1
AbCip and AbLegacy have a second, managed wire transport (`Driver.Ab.Transport`) beside libplctag, chosen by
the driver-config key `transport` = `Native` | `Libplctag` (string; takes effect when the driver is
constructed — every process start, so **the upgrade restart alone moves no-key drivers to Native**; set
`"transport": "Libplctag"` BEFORE upgrading to stay put; AdminUI "Transport" dropdown; CLIs `--transport`). **Native is the default (Task 22):**
an ABSENT key resolves to `Native` via `AbCipTransportResolver` / `AbLegacyTransportResolver` (driver, probe and
CLI share them) — except an AbLegacy driver with any PLC-5 / `LogixPccc` device, which resolves to `Libplctag`.
"Default" is encoded by omitting the key; the AdminUI must never re-emit a concrete default. Evidence: AbCip
reads proven on a real CompactLogix (Z-35162, read-only); AbCip writes and all AbLegacy are `ab_server`-only.
Fall back with `"transport": "Libplctag"`. ⚠️ AbLegacy native refuses status-bit sub-element
writes (`T4:0.DN`…, client sees `BadNotSupported`; refusals never evict or degrade) and its ST byte order is unverified. 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
**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)
+44 -20
View File
@@ -89,23 +89,28 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
| Key | Values | Default | Serialization |
|---|---|---|---|
| `transport` | absent \| `Native` \| `Libplctag` | absent ⇒ `Native` | string, case-insensitive on read, written as `"Libplctag"` / `"Native"`; "use the default" is encoded by OMITTING the key |
| `transport` | absent \| `Native` \| `Libplctag` | absent ⇒ the node's `Drivers:AbCip:DefaultTransport` (itself default `Native`) | string, case-insensitive on read, written as `"Libplctag"` / `"Native"`; "use the default" is encoded by OMITTING the key |
- **`Native`** (the default since Task 22) is a managed in-process EtherNet/IP + CIP client
(`Driver.Ab.Transport`).
- **`Libplctag`** (fallback) drives the libplctag native library, as before.
- An absent key means "use the default". It is resolved in one place, `AbCipTransportResolver`, which the
driver, the Test Connect probe and the CLI all go through. An explicit value always wins.
driver, the Test Connect probe and the CLI all go through. **Precedence:** an explicit per-driver key →
the node-level appsetting **`Drivers:AbCip:DefaultTransport`** (`Native` | `Libplctag`, env
`Drivers__AbCip__DefaultTransport`; a bad value fails host start; see
[Configuration.md](../Configuration.md#driversabcip-node-level-abcip-defaults)) → compiled `Native`. The
Test Connect probe uses the same node value, so it probes the stack the driver would run. The CLI has no
node config and uses compiled `Native`.
- A bad value fails like a bad `PlcFamily`; the Test Connect probe reports "Config JSON is invalid".
- A change takes effect when the driver instance is **constructed**: on every process start (so the upgrade
restart moves no-key drivers to Native — see the release note) and when a deploy that changes `DriverConfig`
does this (`DriverHostActor` respawns the child, #516). `ReinitializeAsync` alone keeps the transport
it was constructed with.
- AdminUI: the **Transport** dropdown in the advanced section of the AbCip driver form ("Default (Native)" /
"Native (managed)" / "libplctag (fallback)"). **Default omits the key on save**, so a defaulted driver stays
- 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)`, including reconnect re-inits. AbCip
`transport=Native (default)` or `transport=Libplctag (node default)`, including reconnect re-inits. AbCip
drivers now receive the host logger (they previously ran on a null logger, so none of their warnings
reached the node log); expect previously invisible warnings, e.g. a probe enabled with no probe tag path.
- CLI: `--transport default|native|libplctag` (default `default`, which goes through the same resolver).
@@ -118,20 +123,34 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
`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).
- **To fall back**, set `"transport": "Libplctag"` in the driver config (or pick "libplctag (fallback)" in
the AdminUI dropdown).
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 Native
the moment the upgraded node starts** — no deploy is needed. To keep a driver on libplctag, add
`"transport": "Libplctag"` and deploy it **before upgrading**: older builds ignore the unknown key. After the
upgrade, a transport change takes effect when a deploy recreates the driver (not on `ReinitializeAsync`).
(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`), as is all of AbLegacy.
- AbLegacy has its own exclusion: a defaulted AbLegacy driver with any PLC-5 or `logixpccc` device stays on
libplctag (see [AbLegacy.md §Transport](AbLegacy.md#transport-transport-option)).
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
@@ -140,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.
@@ -154,11 +177,12 @@ 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.
+58 -50
View File
@@ -93,7 +93,7 @@ binds from the driver's `DriverConfig` JSON:
- **`Devices`** — one `AbLegacyDeviceOptions` per PLC (`HostAddress`, `PlcFamily`, optional `DeviceName`).
- **`Tags`** — pre-declared `AbLegacyTagDefinition` list (`Name`, `DeviceHostAddress`, `Address`, `DataType`, `Writable`, `WriteIdempotent`).
- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeAddress`.
- **`Transport`** — absent (default ⇒ `Native`, but `Libplctag` when any device is PLC-5 / `LogixPccc`), `Native`, or `Libplctag` (fallback); 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).
@@ -105,66 +105,72 @@ written as `"Libplctag"` / `"Native"`; "use the default" is encoded by omitting
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 `Native`, **unless any configured device has `PlcFamily` `Plc5` or
`LogixPccc`** — then the whole driver resolves to `Libplctag`, because the transport is chosen once per driver,
not per device. 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.
⚠️ **Test Connect in a mixed-family driver can disagree with the runtime.** The AdminUI probe merges the
driver config with **only the device being probed**, so in a defaulted driver holding an SLC 500 and a PLC-5,
Test Connect on the SLC device resolves `Native` while the deployed driver runs every device on `Libplctag`.
SLC 500 works on both stacks, so this is cosmetic; the reverse (probe on libplctag, driver on native) cannot
happen. Set `transport` explicitly to make them agree.
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 (Native; libplctag for PLC-5 / LogixPccc)" (omits the
key on save), "Native (managed)" and "libplctag (fallback)". The driver logs
`transport=<effective> (default|configured)` on every init, e.g. `transport=Libplctag (default; PLC-5/LogixPccc
device present)`. CLI: `--transport default|native|libplctag` (default `default`, so `--plc-type Plc5` with no
`--transport` runs libplctag). See
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 is the default (Task 22)
### Release note — native PCCC transport, opt-in (final review I3)
- ⚠️ **Status-bit writes are refused on the default.** An SLC 500 / MicroLogix driver on the native default
**refuses writes to status-bit sub-elements** — `T4:0.DN`, `T4:0.TT`, `T4:0.EN`, `C5:0.CU`, `C5:0.DN`,
`R6:0.EN`, and the rest. The client sees **`BadNotSupported`**. It is not treated as a comms fault: the
tag's handle is kept and driver health is unchanged. **Nothing is written to the PLC.** libplctag performed a masked bit write here, so an
authored writable status-bit tag that worked before the upgrade fails after it. This is deliberate: a native
read-modify-write of the control word would race the PLC updating TT/DN and could clobber EN/TT/DN, and such
writes are rare. **Drivers that need them must set `"transport": "Libplctag"`.** Reads of status bits are
unaffected. Word sub-elements (`T4:0.PRE`, `T4:0.ACC`, `R6:0.LEN`, `R6:0.POS`) still write natively.
- **The native managed transport is now the default** for SLC 500 and MicroLogix. A driver config with no
`transport` key runs `Native`; libplctag remains available as the fallback.
- **Exclusion:** a defaulted driver with **any PLC-5 or `LogixPccc` device stays on libplctag**. Native PLC-5
support is N-file reads only, and `LogixPccc` framing is unverified. Explicit `Native` is still honoured
there, with a Warning at init.
- ⚠️ **Client-visible:** native reports **`BadTypeMismatch`** for a mis-authored same-width type, where
libplctag returned Good garbage (see [AbCip.md §Native transport behaviour](AbCip.md#native-transport-behaviour)).
- **ST (string) files:** the native ST file byte order is **unverified** (no capture), so string values read or
written natively may be byte-swapped. Drivers with ST tags whose values matter should fall back.
- **To fall back**, set `"transport": "Libplctag"` in the driver config.
- ⚠️ **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
Native the moment the upgraded node starts** — no deploy is needed. To keep a driver on libplctag, add
`"transport": "Libplctag"` and deploy it **before upgrading**: older builds ignore the unknown key. After the
upgrade, a transport change takes effect when a deploy recreates the driver (not on `ReinitializeAsync`).
- **Evidence, and its limits:** all of AbLegacy is **simulator-proven only** (`ab_server`). No real SLC,
MicroLogix or PLC-5 has been exercised. (The real-press gate, Z-35162 2026-10-08, covered AbCip reads only.)
- **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; the real-press gate (Task 21) was read-only and AbCip-only.
- ⚠️ **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). The default excludes PLC-5 and
`logixpccc` (they resolve to libplctag unless `Native` is set explicitly).
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.
@@ -184,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
@@ -199,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
@@ -27,8 +27,9 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
/// <summary>Gets the transport stack used to reach the PLC.</summary>
[CommandOption("transport", Description =
"Transport stack: default, native or libplctag. default = native, except libplctag for --plc-type " +
"Plc5 / LogixPccc (native there is PLC-5 N-file reads only, no status-bit writes, LogixPccc unverified).")]
"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>
@@ -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>
@@ -34,7 +34,8 @@ public sealed class AbCipDriverOptions
/// <summary>
/// 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", which is <see cref="AbCipTransport.Native"/>;
/// <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
@@ -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,
}
@@ -141,19 +141,23 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <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 = AbCipTransportResolver.Resolve(options);
_transportDescription = AbCipTransportResolver.Describe(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;
@@ -182,7 +186,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <summary>The effective transport resolved at construction (see <see cref="AbCipTransportResolver"/>).
/// Describes the OPTION only: injected factories (tests) are used regardless.</summary>
internal AbCipTransport EffectiveTransport => _transport;
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.
@@ -191,6 +195,18 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// 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);
@@ -803,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.
@@ -830,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
@@ -861,6 +892,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
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
@@ -907,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);
@@ -940,6 +989,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
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);
@@ -1023,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));
@@ -1085,6 +1150,16 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
"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.
@@ -155,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(AbCipTransportResolver.Resolve(opts)).Tags).Create(p);
var rt = (_tagFactory ?? AbCipTransportFactories.For(AbCipTransportResolver.Resolve(opts, _defaultTransport)).Tags).Create(p);
try
{
await rt.InitializeAsync(cts.Token);
@@ -5,32 +5,61 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <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 transport an absent / null <c>transport</c> key selects.</summary>
/// <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 <see cref="Default"/>.</summary>
/// <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) => configured ?? Default;
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)
public static AbCipTransport Resolve(AbCipDriverOptions options, AbCipTransport nodeDefault = Default)
{
ArgumentNullException.ThrowIfNull(options);
return Resolve(options.Transport);
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, the bare transport name when it was explicit.
/// <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) =>
configured is null ? $"{Resolve(configured)} (default)" : $"{configured} (configured)";
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);
}
@@ -27,9 +27,9 @@ public sealed class AbLegacyDriverOptions
/// <summary>
/// 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.Native"/>, UNLESS
/// any configured device is a PLC-5 or LogixPccc (limited native support), in which case the whole
/// driver resolves to <see cref="AbLegacyTransport.Libplctag"/>. An explicit value always wins. 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
@@ -72,7 +72,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
// 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 = AbLegacyTransportResolver.Resolve(options);
_transportDescription = AbLegacyTransportResolver.Describe(options.Transport, _transport);
_transportDescription = AbLegacyTransportResolver.Describe(options.Transport);
_tagFactory = tagFactory ?? (_transport == AbLegacyTransport.Native
? new NativeAbLegacyTagFactory()
: new LibplctagLegacyTagFactory());
@@ -103,7 +103,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
// 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. "Native (default)".
// 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.
@@ -157,8 +157,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
if (HasConfigBody(driverConfigJson))
_options = AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
// The resolver only defaults a PLC-5 / LogixPccc driver to libplctag; an operator may still pick
// Native explicitly. Allowed, but the limitation is client-visible, so name it ONCE per instance —
// 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
@@ -97,7 +97,7 @@ public static class AbLegacyDriverFactoryExtensions
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
},
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
// Absent ⇒ null ("use the default", resolved by AbLegacyTransportResolver against the devices);
// 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
@@ -153,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; }
@@ -4,16 +4,13 @@ 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"/> and its devices. The driver constructor, the Test
/// Connect probe and the CLI (through the driver) all resolve through here.
/// <para>An absent key means <see cref="AbLegacyTransport.Native"/> — UNLESS any configured device is a
/// <see cref="AbLegacyPlcFamily.Plc5"/> or <see cref="AbLegacyPlcFamily.LogixPccc"/>, whose native support
/// is limited (PLC-5: N-file reads only; LogixPccc: framing unverified). The tag factory is chosen once
/// per DRIVER, not per device, so one such device resolves the whole driver to
/// <see cref="AbLegacyTransport.Libplctag"/>. An explicit value always wins (an operator choice).</para>
/// <para>The probe resolves from the config it is GIVEN. The AdminUI merges only the probed device into
/// that config, so in a mixed-family defaulted driver Test Connect on an SLC device can resolve Native
/// while the deployed driver resolves Libplctag (documented in docs/drivers/AbLegacy.md).</para>
/// <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
{
@@ -32,16 +29,13 @@ public static class AbLegacyTransportResolver
return [.. devices.Select(d => d.PlcFamily).Where(IsNativeLimited).Distinct()];
}
/// <summary>Returns the effective transport for a configured value and device set.</summary>
/// <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>
/// <param name="devices">The configured devices.</param>
/// <returns>The transport the driver runs on.</returns>
public static AbLegacyTransport Resolve(AbLegacyTransport? configured, IEnumerable<AbLegacyDeviceOptions> devices)
{
ArgumentNullException.ThrowIfNull(devices);
if (configured is { } explicitValue) return explicitValue;
return devices.Any(d => IsNativeLimited(d.PlcFamily)) ? AbLegacyTransport.Libplctag : AbLegacyTransport.Native;
}
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>
@@ -49,19 +43,15 @@ public static class AbLegacyTransportResolver
public static AbLegacyTransport Resolve(AbLegacyDriverOptions options)
{
ArgumentNullException.ThrowIfNull(options);
return Resolve(options.Transport, options.Devices);
return Resolve(options.Transport);
}
/// <summary>
/// Operator-facing description of the configured-vs-effective transport for the init log line, e.g.
/// <c>Native (default)</c>, <c>Libplctag (default; PLC-5/LogixPccc device present)</c> or
/// <c>Native (configured)</c>.
/// 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>
/// <param name="effective">The effective transport.</param>
/// <returns>A short description.</returns>
public static string Describe(AbLegacyTransport? configured, AbLegacyTransport effective) =>
configured is not null ? $"{effective} (configured)"
: effective == AbLegacyTransport.Libplctag ? $"{effective} (default; PLC-5/LogixPccc device present)"
: $"{effective} (default)";
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,11 +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="">Default (Native)</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. Default omits the setting so the driver picks; 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>
@@ -22,11 +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="">Default (Native; libplctag for PLC-5 / LogixPccc)</option>
<option value="@AbLegacyTransport.Native">Native (managed)</option>
<option value="@AbLegacyTransport.Libplctag">libplctag (fallback)</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. Default omits the setting so the driver picks; 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>
@@ -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();
@@ -162,13 +162,12 @@ public sealed class BuildOptionsTests
}
/// <summary>
/// With no <c>--transport</c> the CLI leaves the option null and the driver's resolver decides: Native
/// for an SLC 500, libplctag for <c>--plc-type Plc5</c> / LogixPccc (Task 22 exclusion). An explicit
/// choice is forwarded verbatim and wins.
/// 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.Native)]
[InlineData(AbLegacyPlcFamily.MicroLogix, AbLegacyTransport.Native)]
[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)
@@ -186,13 +185,15 @@ public sealed class BuildOptionsTests
}
/// <summary>
/// The plan's CLI case end to end through CliFx: <c>--plc-type plc5</c> with no <c>--transport</c>
/// resolves to libplctag; the same invocation for an SLC resolves Native (falsification control).
/// 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.Native)]
[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;
@@ -191,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>
@@ -15,12 +15,12 @@ public sealed class AbLegacyTransportInitLogTests
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 (Task 22).</summary>
/// <summary>The init line names the configured-vs-effective transport (final review I3: default = Libplctag).</summary>
[Theory]
[InlineData("Native", "Slc500", "Native (configured)")]
[InlineData("Libplctag", "Slc500", "Libplctag (configured)")]
[InlineData(null, "Slc500", "Native (default)")]
[InlineData(null, "Plc5", "Libplctag (default; PLC-5/LogixPccc device present)")]
[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)
{
@@ -37,6 +37,7 @@ public sealed class AbLegacyTransportInitLogTests
[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)
@@ -24,13 +24,13 @@ public sealed class AbLegacyTransportSwitchTests
/// <summary>
/// Transport values select the factory type; absent parses to null ("use the default") and the driver
/// resolves that to Native for an SLC 500 (Task 22). An explicit Libplctag still wins.
/// 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)]
[InlineData("\"Libplctag\"", AbLegacyTransport.Libplctag)]
[InlineData(null, AbLegacyTransport.Native)]
[InlineData(null, AbLegacyTransport.Libplctag)]
public void Config_transport_selects_the_factory(string? json, AbLegacyTransport expected)
{
var parsed = AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config(json));
@@ -46,35 +46,42 @@ public sealed class AbLegacyTransportSwitchTests
}
/// <summary>
/// The exclusion: an absent key resolves the WHOLE driver to libplctag when any device is a PLC-5 or a
/// LogixPccc (limited native support); the factory is driver-level, so one such device among SLCs
/// is enough. The SLC/MicroLogix-only rows are the falsification control — they must resolve Native.
/// 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(AbLegacyTransport.Libplctag, "Plc5")]
[InlineData(AbLegacyTransport.Libplctag, "LogixPccc")]
[InlineData(AbLegacyTransport.Libplctag, "Slc500", "Plc5")]
[InlineData(AbLegacyTransport.Libplctag, "MicroLogix", "LogixPccc")]
[InlineData(AbLegacyTransport.Native, "Slc500")]
[InlineData(AbLegacyTransport.Native, "MicroLogix")]
[InlineData(AbLegacyTransport.Native, "Slc500", "MicroLogix")]
public void Absent_transport_excludes_PLC5_and_LogixPccc(AbLegacyTransport expected, params string[] families)
[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(expected);
AbLegacyTransportResolver.Resolve(AbLegacyDriverFactoryExtensions.ParseOptions("ab1", json))
.ShouldBe(AbLegacyTransport.Libplctag);
var driver = AbLegacyDriverFactoryExtensions.CreateInstance("ab1", json);
driver.EffectiveTransport.ShouldBe(expected);
if (expected == AbLegacyTransport.Native)
driver.TagFactory.ShouldBeOfType<NativeAbLegacyTagFactory>();
else
driver.TagFactory.ShouldBeOfType<LibplctagLegacyTagFactory>();
driver.EffectiveTransport.ShouldBe(AbLegacyTransport.Libplctag);
driver.TagFactory.ShouldBeOfType<LibplctagLegacyTagFactory>();
}
/// <summary>An explicit value always wins over the exclusion, in both directions.</summary>
/// <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)
{
@@ -171,8 +178,8 @@ public sealed class AbLegacyTransportSwitchTests
/// <summary>
/// The probe resolves an ABSENT transport through the same resolver as the driver: an SLC 500 with no
/// key takes the native path (native failure text), a PLC-5 with no key takes libplctag (no native
/// text) — against the same listener, so only the resolution differs.
/// 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()
@@ -191,19 +198,23 @@ public sealed class AbLegacyTransportSwitchTests
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.Native);
.ShouldBe(AbLegacyTransport.Libplctag);
AbLegacyTransportResolver.Resolve(AbLegacyDriverFactoryExtensions.ParseOptions("p", Cfg("Plc5")))
.ShouldBe(AbLegacyTransport.Libplctag);
slc.Ok.ShouldBeFalse();
slc.Message!.ShouldContain("PCCC handshake failed: Connection lost");
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()
{
@@ -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.
@@ -144,8 +144,7 @@ public sealed class AbLegacyDriverPageFormSerializationTests
public void A_config_without_transport_loads_as_Default_and_an_unrelated_edit_keeps_the_key_absent()
{
// 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.
// 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);
@@ -168,4 +167,24 @@ public sealed class AbLegacyDriverPageFormSerializationTests
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");
}
}
@@ -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);
}
}