Merge native .NET Allen-Bradley transport (AbCip native default; AbLegacy native opt-in; real-press gated)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-07 22:40:06 -04:00
201 changed files with 20501 additions and 545 deletions
+32
View File
@@ -973,6 +973,38 @@ docker-dev. The rationale for every decision above is in
Dev/test LDAP is the **shared GLAuth** running on the Linux Docker host at `10.100.0.35:3893` (baseDN `dc=zb,dc=local`, plaintext/`Transport=None`). It is managed via `scadaproj/infra/glauth/` (source of truth + deploy runbook). Single bind account `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123`; all test users password `password`. The docker-dev compose binds this shared instance directly — `DevStubMode` is no longer used. The per-VM NSSM GLAuth at `C:\publish\glauth\` and the old base DNs `dc=lmxopcua,dc=local` / `dc=otopcua,dc=local` are obsolete. (The integration-test harness under `tests/.../Host.IntegrationTests/` runs its own ephemeral **GLAuth** container on port 3894 — seeded by `tests/.../Host.IntegrationTests/glauth/config.toml` with dev users alice/bob + the `cn=serviceaccount` bind account — for the opt-in `OTOPCUA_HARNESS_USE_LDAP=1` real-bind mode; distinct from the shared dev GLAuth on 3893. It replaced the retired `bitnami/openldap:2.6` in PR #451, 2026-07-15.)
## AB drivers: native transport (`Transport`)
AbCip and AbLegacy have a second, managed wire transport (`Driver.Ab.Transport`) beside libplctag, chosen by
the driver-config key `transport` = `Native` | `Libplctag` (string; takes effect when the driver is
**constructed** — every process start, or a deploy that recreates it, not `ReinitializeAsync`; AdminUI
"Transport" dropdown; CLIs `--transport`). "Default" is encoded by omitting the key; the AdminUI must never
re-emit a concrete default. The defaults differ per driver (final review, 2026-10-07):
- **AbCip: an absent key resolves to the node default, `Native` unless pinned.** Precedence (one place,
`AbCipTransportResolver`, shared by driver, probe and CLI): explicit per-driver key → node-level appsetting
**`Drivers:AbCip:DefaultTransport`** (`Native` | `Libplctag`, env `Drivers__AbCip__DefaultTransport`,
`AbCipDriverDefaultsOptions`; a bad value **fails host start**) → compiled `Native`. Bound unconditionally
because the driver factory AND the admin-node Test Connect probe both read it. ⚠️ So **the upgrade restart
alone moves no-key AbCip drivers to Native**; to stay on libplctag, set the node key — NOT a per-driver key
through the old AdminUI, whose typed form drops unknown keys (#576). Evidence: AbCip reads proven on a real
CompactLogix (Z-35162, read-only); AbCip writes are `ab_server`-only.
- **AbLegacy: an absent key resolves to `Libplctag` for every family; `Native` is opt-in** (no node key). On
a wire the native PCCC path is proven only for **N-file reads/writes and one F8 read**, all on `ab_server`;
`S:0` (the default probe), B/T/C/R/S/L/ST/A/O/I and the T/C/R sub-element offsets are encoded from the PCCC
table and **unverified natively**. Explicit Native on PLC-5/`LogixPccc` logs a once-per-instance Warning.
AbLegacy native refuses status-bit sub-element writes (`T4:0.DN`…, `BadNotSupported`) and its ST byte order
is unverified.
Refusals are not comms faults: an Unsupported refusal (`BadNotSupported`) and an AbCip native **type
mismatch** (`BadTypeMismatch`, where libplctag returned Good garbage for a same-width mis-authored type) both
keep the tag handle, leave driver health alone (no `Degraded`, nothing for the comms-loss watchdog) and warn
once per tag — only genuine transport failures evict and degrade. `DeviceSessionPool.Shared` logs through the
host logger factory (wired in `DriverFactoryBootstrap`). libplctag is confined to `Libplctag*.cs` adapters by
`LibplctagConfinementGuardTests`; do not reference it elsewhere. See `docs/drivers/AbCip.md` §Transport,
`docs/drivers/AbLegacy.md` §Transport + §Wire evidence, `docs/Configuration.md` §`Drivers:AbCip`, and
`docs/plans/2026-10-07-native-ab-transport-gate-results.md`.
## Library Preferences
- **Logging**: Serilog with rolling daily file sink
+2
View File
@@ -40,6 +40,7 @@
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj" />
@@ -109,6 +110,7 @@
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj" />
+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`)
+123 -1
View File
@@ -76,12 +76,134 @@ the driver's `DriverConfig` JSON. Key fields:
- **`Devices`** — one `AbCipDeviceOptions` per PLC (`HostAddress`, `PlcFamily`, optional `DeviceName`, per-device `AllowPacking` / `ConnectionSize` overrides).
- **`Tags`** — pre-declared `AbCipTagDefinition` list; `Members` for UDT fan-out, `SafetyTag` for GuardLogix safety-partition tags.
- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeTagPath`.
- **`Transport`** — absent (default ⇒ `Native`), `Native`, or `Libplctag` (fallback); see [Transport](#transport-transport-option).
- **Discovery** — `EnableControllerBrowse` (`@tags` walk) and `EnableDeclarationOnlyUdtGrouping` (whole-UDT read fast path).
- **Alarms** — `EnableAlarmProjection` + `AlarmPollInterval` for the ALMD projection.
Full per-field descriptions live in `AbCipDriverOptions.cs`. The JSON skeleton is
reproduced in [docs/v2/driver-specs.md §3](../v2/driver-specs.md).
## Transport (`transport` option)
The driver has two interchangeable wire transports, selected by one `DriverConfig` key:
| Key | Values | Default | Serialization |
|---|---|---|---|
| `transport` | absent \| `Native` \| `Libplctag` | absent ⇒ the node's `Drivers:AbCip:DefaultTransport` (itself default `Native`) | string, case-insensitive on read, written as `"Libplctag"` / `"Native"`; "use the default" is encoded by OMITTING the key |
- **`Native`** (the default since Task 22) is a managed in-process EtherNet/IP + CIP client
(`Driver.Ab.Transport`).
- **`Libplctag`** (fallback) drives the libplctag native library, as before.
- An absent key means "use the default". It is resolved in one place, `AbCipTransportResolver`, which the
driver, the Test Connect probe and the CLI all go through. **Precedence:** an explicit per-driver key →
the node-level appsetting **`Drivers:AbCip:DefaultTransport`** (`Native` | `Libplctag`, env
`Drivers__AbCip__DefaultTransport`; a bad value fails host start; see
[Configuration.md](../Configuration.md#driversabcip-node-level-abcip-defaults)) → compiled `Native`. The
Test Connect probe uses the same node value, so it probes the stack the driver would run. The CLI has no
node config and uses compiled `Native`.
- A bad value fails like a bad `PlcFamily`; the Test Connect probe reports "Config JSON is invalid".
- A change takes effect when the driver instance is **constructed**: on every process start (so the upgrade
restart moves no-key drivers to Native — see the release note) and when a deploy that changes `DriverConfig`
does this (`DriverHostActor` respawns the child, #516). `ReinitializeAsync` alone keeps the transport
it was constructed with.
- AdminUI: the **Transport** dropdown in the advanced section of the AbCip driver form ("Default (node
setting; Native unless pinned)" / "Native (managed)" / "libplctag (fallback)"). **Default omits the key on save**, so a defaulted driver stays
defaulted; saving another field preserves an explicit value and preserves absence.
- The driver logs `transport=<effective> (default|configured)` at Information on every init, e.g.
`transport=Native (default)` or `transport=Libplctag (node default)`, including reconnect re-inits. AbCip
drivers now receive the host logger (they previously ran on a null logger, so none of their warnings
reached the node log); expect previously invisible warnings, e.g. a probe enabled with no probe tag path.
- CLI: `--transport default|native|libplctag` (default `default`, which goes through the same resolver).
- libplctag is confined to `Libplctag*.cs` adapters, enforced by a source-scan guard
(`LibplctagConfinementGuardTests`), so removing it later is mechanical.
### Release note — native is the default (Task 22)
- **The native managed transport is now the default.** A driver config with no `transport` key runs
`Native`; libplctag remains available as the fallback.
- ⚠️ **Client-visible:** native reports **`BadTypeMismatch`** for a mis-authored same-width type (for example a
tag authored `DINT` where the controller holds a `REAL`), where libplctag returned Good garbage. Correct the
authored type; see [Native transport behaviour](#native-transport-behaviour). It does not degrade driver
health or churn the connection; one Warning per tag names it.
- **To fall back** one driver, set `"transport": "Libplctag"` in its driver config (or pick "libplctag
(fallback)" in the AdminUI dropdown). To fall back a whole node, set `Drivers:AbCip:DefaultTransport` to
`Libplctag` (below).
- ⚠️ **When it takes effect: the upgrade restart itself.** Every process start constructs the drivers
(including boot-from-cache on a driver-only node), so **every driver with no `transport` key moves to the
node default — `Native` unless pinned — the moment the upgraded node starts**; no deploy is needed. After the
upgrade, a per-driver transport change takes effect when a deploy recreates the driver (not on
`ReinitializeAsync`).
- **To stay on libplctag across the upgrade (pin before upgrading):** set the node-level key on each node
you upgrade, in the same change that installs the new build — `appsettings.json`
`"Drivers": { "AbCip": { "DefaultTransport": "Libplctag" } }`, or the environment variable
`Drivers__AbCip__DefaultTransport=Libplctag` on the service. Older builds ignore the section, so it can be
staged ahead of the binaries. Every AbCip driver with no `transport` key then starts on libplctag; the init
log shows `transport=Libplctag (node default)`. Move to Native later per driver (explicit
`"transport": "Native"` in the new AdminUI) or for the node (remove the key / set `Native`, then restart).
⚠️ Do **not** rely on adding `"transport": "Libplctag"` to driver configs through the **old** AdminUI before
upgrading: its typed AbCip form has no Transport field and drops unknown keys on save (#576), and on a fused
`admin,driver` node the AdminUI and drivers upgrade together, so there is no window. The node key works on
every node shape, including fused nodes and driver-only nodes booting from cache.
- **Evidence, and its limits:** AbCip **reads** (plus browse and templates) were proven on a production
CompactLogix 5069-L320ERS2 (**Z-35162**, read-only, 2026-10-08; see
[gate results](../plans/2026-10-07-native-ab-transport-gate-results.md) §Real press). AbCip **writes** are
simulator-proven only (`ab_server`).
- **AbLegacy is different: its default stays libplctag** and Native is opt-in per driver; the native PCCC
path has wire evidence (on `ab_server`) only for N-file reads/writes and one F8 read (see
[AbLegacy.md §Transport](AbLegacy.md#transport-transport-option)).
## Native transport behaviour
- **One CIP connection and one request scheduler per device** (`DeviceSessionPool`), shared by reads,
writes, browse and template reads. The Multiple Service Packet scheduler coalesces requests with one
request in flight; large reads (listing pages, Read Template) are sent unpacked and exclusive.
- **Forward Open throttle:** at most one attempt in flight, at least 1 s between starts, bounded by the
open budget.
- `ConnectionSize` / `Timeout` / `AllowPacking` are honoured per pooled connection, and the **first**
driver/device to open a given device key wins — including a Test Connect probe on a fused `admin,driver`
node (its connection carries the probe timeout). A later opener with different options is ignored and
logged once as a Warning (`DeviceSessionPool`). ⚠️ The device key compares the **host string**, so the
same controller addressed by DNS name in one driver and by IP in another gets two connections and two
Forward Open throttles (up to two opens per second); address a controller the same way everywhere. ControlLogix, GuardLogix and (since T21) CompactLogix default to 4002, a Large
Forward Open (0x5B), the same as libplctag. A target that refuses Large gets one regular open at
min(size, 504) inside the same throttled attempt. An explicit `ConnectionSize` in the options or on the
device overrides the family default.
- **Routing:** Micro800 is connected with an empty route regardless of `UseConnectedMessaging`.
SLC500/MicroLogix/PLC-5 over this driver are direct. `logixpccc` uses UnconnectedSend; its SLC framing is
**unverified**.
- ⚠️ **Type check (client-visible change).** The native path compares the type code the controller returns
with the authored `DataType` and reports **`BadTypeMismatch`** on disagreement. libplctag never checks it,
so a same-width mis-authored tag read as **Good garbage**: authoring a tag as `DINT` while the controller
holds a `REAL` returns the REAL's bits as an integer with a Good status under libplctag, and
`BadTypeMismatch` natively. That is the fix working, but a consumer will see Bad quality on tags that
previously looked healthy: correct the authored type. Same-width signed/unsigned pairs are exempt, and
writes are gated on a mismatch too. A mismatch is a **deterministic configuration refusal**, handled like
an Unsupported refusal (final review I4): the tag's handle is kept (no re-create, no reconnect), driver
health is unchanged (no `Degraded`, nothing fed to the comms-loss watchdog), and the Warning is logged
**once per tag** per driver instance, not every poll. The same applies to the libplctag-path width check.
- Malformed tag names put the device into backoff, as under libplctag (#570). STRING capacity includes the
alignment pad (84, not 82) (#570).
- **A refused operation is not a comms fault.** Any operation a runtime refuses as Unsupported (in AbCip, e.g.
a native write whose type cannot be determined) reaches the client as `BadNotSupported`, keeps the tag
handle, and leaves driver health unchanged; only real transport failures evict and degrade.
## Known gaps
- ⚠️ **Native writes are verified against `ab_server` only (simulator-only), never a real controller.** The
real-press gate (Task 21) was **read-only**: it proved native reads, browse and templates on Z-35162
(2026-10-08) and nothing else. Results:
[native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md).
- **libplctag cannot be deleted** until a bench controller allows write verification.
- Wide-struct fragmented writes (8-byte alignment) are unit-tested only, never proven on a controller (#567).
- Native browse: listing pages are unpaced, and template reads outside an enumeration open a connection per
call (#572).
- Micro800, routed and `logixpccc` paths are covered by `ab_server` only. The Micro800 profile passes
natively. The AbLegacy default excludes `logixpccc` (and PLC-5).
- Follow-up issues for this workstream: #564 to #577.
- Fixed along the way (pre-existing on master): `ab://host:44818/` with an empty CIP path (Micro800,
unrouted MicroLogix) failed on libplctag with `ErrorBadData` because the adapter sent `Path=""`; it now
sends no path.
## Alarm Projection
`IAlarmSource` is served by `AbCipAlarmProjection`, which polls each subscribed
@@ -190,7 +312,7 @@ UDT-typed tags found in the walk.
- **Native heap is invisible to the GC.** libplctag's native `Tag` heap does not show up in CLR allocation figures. Watch whole-process RSS, and use `ReinitializeAsync` (tears down + re-creates every device's libplctag handles) as the remediation for native-heap growth. (There is no per-driver footprint number to consult: `IDriver.GetMemoryFootprint()` was deleted as unconsumed in Gitea #525.)
- **Handle eviction on failure** — a non-zero libplctag status or a transport exception evicts the cached tag runtime so the next read/write re-creates a fresh handle, mirroring the probe loop's recreate-on-failure behaviour.
- **Read statuses carry the libplctag cause** — a tag the controller does not have reads `BadNodeIdUnknown` (not `BadCommunicationError`), and it does **not** open the device's connect-backoff window (the controller answered; before this, one typo throttled every sibling tag on the device). A genuine connection failure still opens it.
- **Width check on scalar reads** — libplctag never checks the CIP type the controller returns. A scalar atomic read whose buffer is not the authored type's width (e.g. a REAL authored as INT — the legacy Kepware export does exactly this for some press tags) reads `BadTypeMismatch` instead of Good garbage. Same-width mistakes (DINT vs REAL) are still undetectable.
- **Width check on scalar reads** — libplctag never checks the CIP type the controller returns. A scalar atomic read whose buffer is not the authored type's width (e.g. a REAL authored as INT — the legacy Kepware export does exactly this for some press tags) reads `BadTypeMismatch` instead of Good garbage. Same-width mistakes (DINT vs REAL) are undetectable under libplctag; the `Native` transport reports them as `BadTypeMismatch` (see [Native transport behaviour](#native-transport-behaviour)).
- **Module-defined I/O tags** — `Local:1:I.Data`, `ABB_OmniCore:I.ConnectionFaulted` and similar colon-bearing base names are valid tag paths (controller scope only).
- **User string arrays** — `STR0016[N]` and similar arrays stride by the actual element size (read buffer ÷ count); libplctag.NET 1.5.2 leaves `ElementSize` unset after a read.
- **Declaration-only UDT grouping is a footgun unless verified** — only enable `EnableDeclarationOnlyUdtGrouping` when every UDT's member declaration order has been hand-verified against the controller's compiled layout.
+11
View File
@@ -32,6 +32,17 @@ still the primary coverage. All tests tagged `[Trait("Category", "Unit")]`.
The driver accepts `IAbLegacyTagFactory` via ctor DI; every test
supplies a `FakeAbLegacyTag`.
## Per-transport suites and capture recipe
The `AbLegacy.IntegrationTests` suites now run **once per transport** (`TransportMatrix`: `Libplctag` and
`Native`). Set `AB_LEGACY_COMPOSE_PROFILE` to the running profile. On the 2026-10-07 run native passed
everything libplctag passed on `slc500`, `micrologix` and `plc5` (the PLC-5 test reads N7 only, so the PLC-5
refusal paths and status-bit writes are not exercised by it). Native PCCC is **simulator-only**. Write-capable
tests (the SLC500 N7:5 round trip) require `AB_SERVER_ALLOW_WRITES=1`. Golden frames:
`scripts/ab-capture/capture.sh`, fixtures under
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/`. Evidence:
[native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md).
## What it actually covers (unit only)
- `AbLegacyAddressTests` — PCCC address parsing for SLC / MicroLogix / PLC-5
+87 -4
View File
@@ -93,10 +93,91 @@ binds from the driver's `DriverConfig` JSON:
- **`Devices`** — one `AbLegacyDeviceOptions` per PLC (`HostAddress`, `PlcFamily`, optional `DeviceName`).
- **`Tags`** — pre-declared `AbLegacyTagDefinition` list (`Name`, `DeviceHostAddress`, `Address`, `DataType`, `Writable`, `WriteIdempotent`).
- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeAddress`.
- **`Transport`** — absent (default ⇒ `Libplctag`), `Native` (opt-in), or `Libplctag`; see [Transport](#transport-transport-option).
Full per-field descriptions live in the contracts assembly. The JSON skeleton is
reproduced in [docs/v2/driver-specs.md §4](../v2/driver-specs.md).
## Transport (`transport` option)
Same switch as AB CIP: `transport` = absent \| `Native` \| `Libplctag`, a string (case-insensitive on read,
written as `"Libplctag"` / `"Native"`; "use the default" is encoded by omitting the key). `Native` is a managed
PCCC-over-EtherNet/IP client (`Driver.Ab.Transport`).
**Default resolution** (one place, `AbLegacyTransportResolver`, used by the driver, the Test Connect probe and
the CLI): an absent key resolves to **`Libplctag` for every PLC family** — unchanged from before the native
transport existed. **`Native` is opt-in** (final review I3: on a wire it is proven only for N-file reads and
writes and one F8 read — see [Wire evidence](#wire-evidence-native-pccc)). An explicit value always wins:
explicit `Native` with a PLC-5 / `LogixPccc` device is allowed (an operator choice) and logs one Warning per
driver instance naming the limitation. (AbCip's node-level `Drivers:AbCip:DefaultTransport` has no AbLegacy
counterpart — the AbLegacy default is libplctag and is not node-configurable.)
A change takes effect when the driver instance is constructed: on a deploy that changes `DriverConfig`
(which recreates it) **and on every process start**, including the restart that installs this build (see the
release note). The
AdminUI form's **Transport** dropdown offers "Default (libplctag)" (omits the key on save), "Native (managed,
opt-in)" and "libplctag". The driver logs `transport=<effective> (default|configured)` on every init, e.g.
`transport=Libplctag (default)`. CLI: `--transport default|native|libplctag` (default `default` = libplctag for
every `--plc-type`). See
[AbCip.md §Transport](AbCip.md#transport-transport-option) for the shared behaviour (one pooled connection
per device, Forward Open throttle, routing).
Native routing: SLC500/MicroLogix/PLC-5 are direct; `logixpccc` uses UnconnectedSend and its SLC framing is
**unverified**.
### Release note — native PCCC transport, opt-in (final review I3)
- **The default is unchanged: libplctag.** A driver config with no `transport` key runs `Libplctag`, for
every PLC family, exactly as before this release. The upgrade restart moves nothing. (An interim build on
this branch defaulted SLC 500 / MicroLogix to Native; that was reverted before merge.)
- **`"transport": "Native"` opts a driver into the managed PCCC-over-EtherNet/IP client** (`Driver.Ab.Transport`).
It takes effect when the driver is constructed — a deploy that changes `DriverConfig`, or a process start —
not on `ReinitializeAsync`. The AdminUI **Transport** dropdown authors it; remove the key (or pick
"Default (libplctag)") to go back.
- **Known limits of Native** (why it is opt-in):
- ⚠️ **Wire evidence is narrow and simulator-only** — see [Wire evidence](#wire-evidence-native-pccc). Only
N-file reads and writes and one F8 read have touched a wire (`ab_server`). The **default probe address
`S:0`** and the **B, T, C, R, S, L, ST, A, O and I file types** — and the T/C/R sub-element offsets
(`.PRE`/`.ACC`/`.LEN`/`.POS`) — are encoded from the PCCC file-type table and are **unverified natively**.
No real SLC, MicroLogix or PLC-5 has been exercised. Opt in per driver after checking it against the real
controller.
- ⚠️ **Status-bit writes are refused** — `T4:0.DN`, `T4:0.TT`, `T4:0.EN`, `C5:0.CU`, `C5:0.DN`, `R6:0.EN`
and the rest return **`BadNotSupported`**; nothing is written to the PLC; the handle is kept and health is
unchanged. libplctag performs a masked bit write here; a native read-modify-write of the control word would
race the PLC updating TT/DN. Reads of status bits are unaffected.
- **PLC-5 native is N-file reads only**; every other PLC-5 operation is refused with `BadNotSupported`.
`LogixPccc` framing is unverified. Explicit Native on either logs a Warning at init.
- **ST (string) byte order is unverified** (no capture); string values may be byte-swapped.
- Unlike AbCip, the native AbLegacy runtime has **no element-type check**, so a mis-authored same-width
type reads as reinterpreted data (as on libplctag), not `BadTypeMismatch`.
### Wire evidence (native PCCC)
Everything below is against the `ab_server` simulator; no real PCCC controller has been exercised.
| What | Evidence |
|---|---|
| N-file read (`N7:0`) | libplctag captures `slc500-read-n7`, `micrologix-read-n7`, `plc5-read-n7` (codec byte parity) + native `AbLegacyReadSmokeTests` read on all three profiles |
| N-file write (`N7:5`) | capture `slc500-write-n7` + native `AbLegacyReadSmokeTests` write/read round trip (SLC 500) |
| F-file read (`F8`) | one capture, `slc500-read-f8` (codec parity only; no native F write on a wire) |
| S (incl. the default probe `S:0`), B, T, C, R, L, ST, A, O, I | **none** — encoded from the PCCC file-type table |
| T/C/R sub-elements (`.PRE`/`.ACC`/`.LEN`/`.POS`), 6-byte T/C/R element layout | **none** |
### Native known gaps
- ⚠️ **Simulator-only, and narrow.** Only N-file reads/writes and one F8 read are on a wire (`ab_server`); see
[Wire evidence](#wire-evidence-native-pccc). No real SLC/MicroLogix/PLC-5 has been exercised; the real-press
gate (Task 21) was read-only and AbCip-only.
- **PLC-5 native is N-file reads only.** Every other file type and all PLC-5 writes are refused before the
wire and reach the client as `BadNotSupported` (handle kept, health unchanged; #571 closed). Native is opt-in
for every family; an explicit Native on PLC-5 / `logixpccc` logs a Warning at init.
- Status-bit sub-element writes (e.g. `T4:0.DN`, `C5:0.DN`) are Unsupported natively (they would clobber the
control word); nothing goes on the wire. Sub-element arrays are Unsupported natively. L-file bit indexes
above 15 are allowed.
- **ST (string) file layout is uncaptured**, so the string byte order is unverified natively.
- Capture recipe for golden frames: `scripts/ab-capture/capture.sh` (see
[AbLegacy-Test-Fixture.md](AbLegacy-Test-Fixture.md)). Follow-up issues: #564 to #577.
## Testing
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` cover the driver, the PCCC address parser, and the host-address parser via fake tag runtimes.
@@ -109,14 +190,16 @@ An AB Legacy tag becomes a **1-D OPC UA array node** when its `TagConfig` JSON c
`"isArray": true` and `"arrayLength": N` (N ≥ 1, capped at 256). The canonical rule:
`isArray: true` + `arrayLength >= 1` → array; `isArray: false` (any length) → scalar.
**Read mechanism** — the driver issues a **PCCC multi-element file read** via libplctag.
The tag's file address (e.g. `N7:0`) is extended by `N` element count so libplctag fetches
**Read mechanism** — the driver issues a **PCCC multi-element file read** (libplctag by default; the opt-in
native transport issues the same typed read, chunked under the PCCC payload limit).
The tag's file address (e.g. `N7:0`) is extended by `N` element count so the transport fetches
`N` consecutive PCCC file elements in one EIP/PCCC request. The response is decoded into
N values of the declared `AbLegacyDataType`. Maximum element count is 256 (PCCC payload
limit).
This closes the previously-noted limitation ("single-element addressing today") for the
array read path. Array *writes* remain a follow-up.
array read path. The opt-in native transport also writes multi-element arrays (chunked); on libplctag
array *writes* remain a follow-up.
**Unit test coverage** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` covers
the multi-element read path via the fake tag runtime. The AB Legacy integration fixture is
@@ -124,7 +207,7 @@ down during normal dev.
**Live-verify** — integration-fixture-gated (requires the AB Legacy Docker fixture).
**Deferrals** — array *writes*, multi-dimensional arrays, per-element historization.
**Deferrals** — array *writes* on libplctag (the opt-in native transport writes them), multi-dimensional arrays, per-element historization.
`arrayLength > 256` is rejected at init with an explicit error (PCCC payload cap).
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
+32 -9
View File
@@ -33,6 +33,29 @@ quirk. UDT / alarm / quirk behavior is verified only by unit tests with
- **Endpoint override**: `AB_SERVER_ENDPOINT=host:port` points the
fixture at a real PLC instead of the local container.
## Per-transport suites and capture recipe
Every integration test in `AbCip.IntegrationTests` now runs **once per transport** (`TransportMatrix`:
`Libplctag` and `Native`). Run one compose profile at a time and name it with `AB_SERVER_COMPOSE_PROFILE`
(all profiles bind `:44818`, so set it to the running profile). On the 2026-10-07 run native passed everything
libplctag passed on all seven profiles; on `micro800` libplctag failed on the empty-path defect described in
[AbCip.md](AbCip.md#known-gaps) and native passed. That defect is now fixed: a re-run of the `micro800`
smoke test passes on both transports. Evidence:
[native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md).
- Write-capable tests (the Logix Emulate ALMD test) require `AB_SERVER_ALLOW_WRITES=1` and refuse while
`AB_DIFF_ENDPOINT` is set.
- **Differential harness** (`AbCip.IntegrationTests/Differential`, `LiveIntegration`) compares libplctag and
native on browse, template and read parity against one endpoint. It is read-only by construction, aborts on
the first connection failure, never retries, and caps the native phase at one Forward Open. Env:
`AB_DIFF_ENDPOINT`, `AB_DIFF_PATH`, `AB_DIFF_FAMILY`, `AB_DIFF_TAGS`, `AB_DIFF_MAX_TAGS` (at most 500),
`AB_DIFF_MAX_TEMPLATES`, `AB_DIFF_PACE_MS` (default 20), `AB_DIFF_COUNT_OPENS`. Always run with
`--filter "FullyQualifiedName~Differential"` and `AB_SERVER_ENDPOINT=127.0.0.1:1`.
- ab_server serves no UDTs and refuses 1-byte writes, so template parity is vacuous there and BOOL/SINT
parity is weak evidence. Native writes remain simulator-only.
- **Golden-frame capture:** `scripts/ab-capture/capture.sh` (capture client in `scripts/ab-capture/client`);
fixtures live in `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/` with a README.
## What it actually covers
- Read path: driver → libplctag → CIP-over-EtherNet/IP → simulator → back.
@@ -93,14 +116,14 @@ Only the `_S`-suffix naming classifier (PR 12, `SecurityClassification.ViewOnly`
forced on safety tags) runs. Actual safety-partition write rejection — what
happens when a non-safety write lands on a real `1756-L8xS` — is not exercised.
### 5. CompactLogix narrow ConnectionSize cap
### 5. CompactLogix connection size and the regular-open fallback
CompactLogix profile `Notes`: *"ab_server lacks the narrower limit itself."*
Driver-side `AbCipPlcFamilyProfile` caps `ConnectionSize` at the CompactLogix
value per PR 10, but `ab_server` accepts whatever the client asks for — the
cap's correctness is trusted from its unit test, never stressed against a
simulator that rejects oversized requests.
Since T21 the CompactLogix profile defaults to a **Large** Forward Open (4002), matching what libplctag
opens for `plc=compactlogix`. The old 504 default was cosmetic under libplctag, which never received it,
and cost the native transport ~3× the round trips per poll. A pre-v20 CompactLogix (1769-L3x) or a bridge that refuses
the Large open is handled by `CipConnection`'s fallback (a regular open at min(size, 504) in the same
throttled attempt). That fallback is pinned by unit tests against a fake target only: `ab_server` accepts
any size, so a refusing controller is never exercised here.
### 6. BOOL-within-DINT read-modify-write (#181)
@@ -145,7 +168,7 @@ skip cleanly + the ab_server Docker fixture runs as usual.
| `@tags` Symbol Object walk with `Program:` scope | partial | **yes** | yes |
| Add-On Instructions | no | **yes** | yes |
| GuardLogix safety-partition write rejection | no | **yes** (Emulate 5580) | yes |
| CompactLogix narrow ConnectionSize enforcement | no | **yes** (5370 firmware) | yes |
| CompactLogix Large-open refusal → regular-open fallback | no | **yes** (5370 firmware) | yes |
| EtherNet/IP embedded-switch behaviour | no | no | yes |
| Redundant chassis failover (1756-RM) | no | no | yes |
| Motion control timing | no | no | yes |
@@ -185,7 +208,7 @@ project is authored.
| "Do ALMD alarms raise + clear?" | no | yes | **yes** | yes |
| "Is Micro800 unconnected-only enforced wire-side?" | partial (--plc=Micro800 boots, but wire rejection untested) | partial | yes | yes (required) |
| "Does GuardLogix reject non-safety writes on safety tags?" | no | no | yes (Emulate 5580) | yes |
| "Does CompactLogix refuse oversized ConnectionSize?" | no | partial | yes (5370 firmware) | yes |
| "Does CompactLogix refuse a Large Forward Open?" | no | partial | yes (5370 firmware) | yes |
| "Does BOOL-in-DINT RMW race against concurrent writers?" | no | yes | partial | yes (stress) |
| "Does EtherNet/IP embedded-switch behave correctly?" | no | no | no | yes (required) |
| "Does redundant-chassis failover work?" | no | no | no | yes (required) |
@@ -0,0 +1,265 @@
# Native .NET transport for AbCip + AbLegacy (replace libplctag) — design
**Date:** 2026-10-07 · **Branch:** `feat/native-ab-transport` · **Status:** design, awaiting review
## 1. Goal
Replace the `libplctag` NuGet (managed wrapper + native `plctag` binary, pinned `1.5.2`) under the
AbCip and AbLegacy drivers with a fully managed EtherNet/IP + CIP + PCCC client owned by this repo.
libplctag is kept, behind a switch, as a fallback until the native path is proven.
### Why (all four apply)
1. **Native-binary pain** — RID-specific packaging, container/ARM images, an unmanaged dependency in
an otherwise all-.NET 10 server.
2. **Correctness and control** — wrapper defects we cannot fix: `ElementSize` returns `null` after a
read (`68cf3ae8`), there is no `ConnectionSize` property (the value is plumbed and dropped),
status codes are opaque, and libplctag never checks the CIP type the controller returns (`e3d6b76e`
could only add a width check).
3. **Performance** — one native tag handle per `(device, tag)`; no control over request packing or
scheduling.
4. **Policy** — no native dependencies; libplctag's LGPL/MPL licensing.
### Non-goals
- No change to the driver surface above the transport seams: the address space, tag-config JSON,
the poll engine, the RMW serialization, the backoff, and the decoders all stay.
- No new PLC families. No CIP Class-1 (implicit I/O) connections. No Omron (`omron-njnx` stays an
unmapped libplctag value only while the fallback exists).
- Whole-UDT **writes** stay unsupported, as they are today.
## 2. Decisions (all taken at the recommended option)
| # | Question | Decision |
|---|---|---|
| D1 | Build or buy | **Build** an in-repo managed stack. Existing managed CIP libraries fail the licensing/control goals. |
| D2 | Cutover | **Side-by-side behind a per-driver `Transport` switch** (`Native` \| `Libplctag`), the same dark-switch discipline as `MeshTransport` / `ConfigSource`. |
| D3 | Proof standard | **Flip per evidence tier** (§8). Native becomes default for AbCip once reads/browse/templates pass the real presses read-only; AbCip writes and all of AbLegacy flip on simulator proof, recorded as a known gap. libplctag stays as the fallback until a bench controller exists. |
| D4 | Where the stack lives | A new shared project, **`ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport`**, referenced by both drivers (precedent: `Mqtt.Transport`, `OpcUaClient.Session`). Pure protocol: no OtOpcUa, no OPC UA, no logging-framework dependency beyond `Microsoft.Extensions.Logging.Abstractions`. |
| D5 | Connection model | **One CIP connection per device** (`host:port` + CIP path), shared by every tag runtime on that device. libplctag already shares internally; the native stack makes it explicit. |
| D6 | Request packing | **Transparent coalescing inside the connection** into Multiple Service Packet (0x0A) requests, within the negotiated connection size, when `AllowPacking`. The `IAbCipTagRuntime` interface does not change. |
| D7 | Status neutrality | A **driver-owned `AbStatus` enum + `AbTransportException`** replace libplctag's `Status` / `LibPlcTagException` in the mappers, probes and `AbCipDriver`. The libplctag adapter translates into them. |
| D8 | Decoder reuse | The native enumerator and template reader **emit the same byte layout libplctag's `@tags` / `@udt/{id}` pseudo-tags return**, so `CipSymbolObjectDecoder` / `CipTemplateObjectDecoder` and their real-capture tests are shared unchanged, and the two transports can be diffed byte for byte. |
| D9 | Primary correctness evidence | **Differential testing**: the same reads through both transports against the same target (`ab_server`, then the real presses read-only), compared on raw buffer and decoded value. |
## 3. Architecture
```
AbCipDriver / AbLegacyDriver (unchanged except factory selection + AbStatus)
│ IAbCipTagFactory / IAbCipTagEnumeratorFactory / IAbCipTemplateReaderFactory / IAbLegacyTagFactory
├── Libplctag* adapters (existing; now translate Status → AbStatus) ← fallback
└── Native* adapters (new, in each driver project)
│
▼
ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport (new, pure managed)
Eip/ EncapsulationClient — TCP framing, RegisterSession/UnRegisterSession,
ListIdentity, SendRRData (UCMM), SendUnitData (connected)
Cip/ CipPath (port segments from "1,0", ANSI symbolic 0x91, member/element
segments 0x28/0x29/0x2A), CipService, CipGeneralStatus + extended status
Cip/ ConnectionManager — Forward Open (0x54) / Large Forward Open (0x5B) /
Forward Close (0x4E), sequence counts, connection-size negotiation,
UCMM-only mode (Micro800)
Cip/ RequestScheduler — per-connection queue, MSP coalescing, in-flight bound,
per-request timeout + cancellation
Logix/ Read Tag (0x4C) / Read Tag Fragmented (0x52) / Write Tag (0x4D) /
Write Tag Fragmented (0x53); Logix type codes incl. struct handle (0xA0)
Logix/ SymbolLister (Get Instance Attribute List 0x55 on class 0x6B, paged)
Logix/ TemplateReader (Get Attribute List 0x03 + Read 0x4C on class 0x6C, chunked)
Pccc/ Execute PCCC (0x4B, class 0x67) with requestor id; SLC/MicroLogix protected
typed logical read/write (0x0F/0xA2, 0x0F/0xAA); PLC-5 typed read/write
(0x0F/0x68, 0x0F/0x67); PCCC STS/EXT STS
DeviceSessionPool — one session+connection per device key, ref-counted
```
### 3.1 Units and their contracts
- **`EncapsulationClient`**: owns one TCP socket. It sends and receives 24-byte-header encapsulation
frames and correlates replies by sender context. It knows nothing about CIP. Testable against canned
byte streams.
- **`CipPath`**: a pure encoder. It turns `"1,0"` (and `"1,0,2,10.0.0.5"` style routes) into port
segments, and turns a Logix tag name such as `Program:P.Arr[3].Member.5` into symbolic, element and
member segments. `AbCipTagPath` already parses names; `CipPath` encodes them. Golden-byte unit tests.
- **`ConnectionManager`**: opens and closes the connected session for a device.
- **Connection size.** It honours `ConnectionSize` (the field libplctag drops today). Above 511
bytes it uses Large Forward Open. If the target rejects a large open (status 0x01, extended 0x0109
or 0x0111), it falls back to a regular Forward Open at the size the target advertises.
- **Micro800** (`SupportsConnectedMessaging = false`) runs UCMM-only: no Forward Open.
- **`RequestScheduler`**: one queue per connection.
- **Coalescing.** Requests that arrive within one scheduling turn are packed into an MSP until the
next service would overflow the connection size. A reply is fanned back to each waiter with its own
per-service status.
- **Oversize requests.** A request too large for one frame goes through the fragmented service loop.
- **Bounds.** At most one in-flight connected request per connection by default (`MaxInFlight`,
default 1), the conservative choice for a production press.
- **`DeviceSessionPool`**: keyed by `(host, port, cipPath, familyAttribute)`. A runtime acquires a
lease on first `InitializeAsync` and releases it on `Dispose`. The last release sends Forward Close
and UnRegisterSession.
### 3.2 Native adapters (in the driver projects)
- **`NativeAbCipTagRuntime : IAbCipTagRuntime`**
- **Reads.** `InitializeAsync` acquires the device lease and resolves the encoded path once.
`ReadAsync` issues Read Tag with `ElementCount`. The reply's **CIP type code + struct handle** is
kept with the buffer.
- **Decoding.** `Decode*` reads the managed buffer, little-endian. Strings use the Logix
`LEN(u32) + DATA[n]` layout with the stride taken from the reply. BOOL arrays are 32-bit packed.
- **Size and status.** `GetSize()` is the real reply length, so the stride fallback in
`ResolveArrayStride` stops being necessary. `GetStatus()` returns `AbStatus`.
- **Writes.** `WriteAsync` issues Write Tag with the type taken from the last read, or from the
authored type for atomics. A struct-typed write (STRING) needs the struct handle, so a write with
no prior read performs one read first.
- **`NativeAbCipTagEnumerator`** reads controller-scope and `Program:X` listings through
`SymbolLister`, and emits the concatenated entry bytes in the `@tags` layout.
- **`NativeAbCipTemplateReader`** emits the 14-byte header that libplctag synthesizes, then the raw
template bytes (D8).
- **`NativeAbLegacyTagRuntime : IAbLegacyTagRuntime`**
- **Addressing.** Encodes `AbLegacyAddress` (file type, file number, element, sub-element) into PCCC
typed-logical addressing, or PLC-5 system addressing for `plc5`.
- **Arrays.** Multi-element reads use the element count.
- **Bits.** Bit-in-word writes stay at the driver level (`WriteBitInWordAsync`), as today.
### 3.3 The switch
- `AbCipDriverOptions.Transport` and `AbLegacyDriverOptions.Transport` take values
`Native | Libplctag`, are serialized as **strings** (`JsonStringEnumConverter`, per the
driver-enum-serialization trap), and are absent ⇒ the current default.
- `Ab*DriverFactoryExtensions.CreateInstance` picks the factory set from the option. Test-injected
factories still win.
- The **probe** (`Ab*DriverProbe`, i.e. Test Connect) honours the same option. A probe that tests a
different transport than the driver runs would be a lie.
- **AdminUI**: a `Transport` dropdown in the AbCip and AbLegacy driver-config modals under the `/raw`
tree. It needs live `/run` verification on docker-dev, since there is no bUnit.
- Default sequence: `Libplctag` at merge, flipped per §8, and the fallback stays selectable.
## 4. Data flow (a poll read)
1. The driver poll calls `runtime.ReadAsync(ct)` on N runtimes for one device. The concurrency
window is unchanged.
2. Each runtime enqueues a Read Tag request on the device's `RequestScheduler`.
3. The scheduler packs as many as fit into one MSP, sends it via SendUnitData on the shared
connection, awaits the reply, splits it by service, and completes each waiter with
`(status, typeCode, structHandle, payload)`.
4. The runtime stores the payload. The driver calls `Decode*` exactly as it does today.
The result: N round-trips through N libplctag handles become ⌈N / fit⌉ round-trips through one
connection. That is the performance win, with no driver change.
## 5. Error handling and status
- **`AbStatus`** (driver-owned) carries the values the mappers and probes use today: `Ok`,
`Pending`, `Timeout`, `NotFound`, `NotAllowed`, `OutOfBounds`, `TooLarge`, `TooSmall`,
`Unsupported`, `BadConnection`, `BadReply`, `Partial`, `Abort`, plus a new `TypeMismatch`.
- **CIP general status mapping (native):**
- `0x00` ⇒ `Ok`
- `0x04` / `0x05` (path segment / destination unknown) ⇒ `NotFound`
- `0x06` ⇒ `Partial` (continue fragmenting)
- `0x08` ⇒ `Unsupported`
- `0x0F` ⇒ `NotAllowed`
- `0x13` ⇒ `TooSmall`
- `0x15` ⇒ `TooLarge`
- `0xFF` ⇒ by extended status (`0x2105` ⇒ `OutOfBounds`, `0x2107` ⇒ `TypeMismatch`)
- socket, encapsulation or timeout faults ⇒ `BadConnection` / `Timeout`
- **PCCC mapping.** STS/EXT STS map the same way: address-does-not-exist ⇒ `NotFound`, illegal
command/format ⇒ `Unsupported`, and so on.
- **`AbTransportException(AbStatus, string detail)`** is thrown by both transports.
`AbCipDriver.cs:1188`'s "the controller answered, so don't throttle the device" check
(`IsReachableStatus`) keys on it. **That preserves 05/STAB-8 semantics.**
- **Type check, native only.** The reply's CIP type code is compared with the authored type.
- A mismatch ⇒ `BadTypeMismatch`. This now also catches **same-width** mistakes (DINT authored,
REAL returned) that `e3d6b76e` documents as undetectable.
- ⚠️ **This is client-visible.** Tags authored wrong from the legacy Kepware export that read Good
garbage under libplctag will read Bad under native. That is the fix working, but it must be
in the release note and in `docs/drivers/AbCip.md`.
- **Press safety.**
- **Reconnect.** A dropped connection is re-opened through the driver's existing `device.Backoff`,
never in a tight loop.
- **One open at a time.** `ConnectionManager` enforces at most one Forward Open in flight per
device, plus a minimum interval between opens (default 1 s).
- **Pinned by a test.** A regression test asserts a bounded Forward Open rate against a refusing
target. ignitionoee recorded **~15 Forward Opens/s** at a press from a misconfigured client.
- **Close.** Forward Close on dispose. The connection timeout multiplier and RPI are set so a dead
client's connection expires on the PLC within seconds, not minutes.
## 6. Packaging
- New project `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/` and its test project
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/`, both added to
`ZB.MOM.WW.OtOpcUa.slnx`.
- While the fallback exists, both drivers keep the `libplctag` package. Libplctag code is confined
to the `Libplctag*.cs` adapters. After phase 2 no other file has `using libplctag`, and a
source-scan guard test enforces that.
- On deletion (phase 6) the package, its `Directory.Packages.props` pin, the adapters and the
`Libplctag*` create-param members (`LibplctagGateway`; `LibplctagPlcAttribute` is renamed
`PlcAttribute`) go.
## 7. Testing
1. **Golden-frame unit tests** (`Ab.Transport.Tests`).
- **Encode** every frame type: RegisterSession, Forward Open / Large / Close, MSP, Read / Write
(Fragmented), 0x55 listing, template attribute reads, Execute PCCC.
- **Decode** every reply type, including error replies.
- **Fixtures** are byte captures taken once from libplctag talking to `ab_server` (pcap, stored as
hex), plus the **existing real-press captures** in `CipSymbolObjectWireCaptureTests` and
`CipTemplateObjectWireCaptureTests`.
- ⚠️ A capture from libplctag ⇄ `ab_server` proves wire parity with libplctag, not correctness. The
real-press captures are the independent reference (the #468 lesson: verify a fixture with the
reference client).
2. **Scheduler and connection tests.** These run against an in-process fake encapsulation endpoint.
They cover:
- MSP packing boundaries at connection size −1, exact and +1
- per-service error fan-out
- cancellation mid-MSP
- the Forward Open rate bound
- large-open fallback
- lease ref-counting and Forward Close on last release
3. **Integration suites run on both transports.** The existing `AbCip.IntegrationTests` and
`AbLegacy.IntegrationTests` against `ab_server` (controllogix / compactlogix / micro800 / guardlogix;
slc500 / micrologix / plc5) are parameterized over `Transport`. A native run must be green on every
test the libplctag run is green on.
4. **Differential harness.** A `LiveIntegration`-category test, env-gated like the HistorianGateway
live suite and skipped when the env vars are absent. It reads the same tag set through both
transports and asserts equal raw buffers and equal decoded values:
- against `ab_server`;
- against a real press, **read-only**. It runs browse (`@tags` + every program listing), every
template, and a sample of scalar, array, STRING and UDT-member reads.
5. **AdminUI `/run`.** Author `Transport: Native` on a docker-dev AbCip driver pointed at the
`ab_server` fixture, deploy, read, then flip it back.
## 8. Phased delivery and gates
| Phase | Delivers | Gate |
|---|---|---|
| **P0 Seam** | `AbStatus` + `AbTransportException`; mappers, probes, `AbCipDriver` and `AbLegacyDriver` decoupled from libplctag types; the 9 unit-test files that build `LibPlcTagException` / `Status` fakes (`AbCipDriverWriteTests`, `AbCipDriverProbeTests`, `AbCipDriverReadTests`, `AbCipStatusMapperTests`, `AbCipDriverCodeReviewRegressionTests`, `AbCipUnknownTagReadTests`, `AbLegacyEvictOnFailureTests`, `AbLegacyReadWriteTests`, `AbLegacyHostAndStatusTests`) moved onto `AbStatus` / `AbTransportException`, with the libplctag translation itself tested in the adapters; the `Transport` option (string enum) + factory selection, including a `--transport` flag on `otopcua-abcip-cli` / `otopcua-ablegacy-cli` (whose help text names libplctag); the source-scan guard | Existing suites green; behaviour identical (default `Libplctag`) |
| **P1 Transport core** | Encapsulation, `CipPath`, `ConnectionManager`, `RequestScheduler` (MSP + fragmentation), `DeviceSessionPool` | Golden-frame + scheduler tests |
| **P2 AbCip native** | `NativeAbCipTagRuntime`, enumerator, template reader; CIP type check | AbCip integration suite green on both transports vs `ab_server` |
| **P3 AbLegacy native** | PCCC layer + `NativeAbLegacyTagRuntime` | AbLegacy integration suite green on both transports vs `ab_server` (slc500 / micrologix / plc5) |
| **P4 Switch surface** | AdminUI dropdown, probe honours the switch, docs (`AbCip.md`, `AbLegacy.md`, fixture docs) | docker-dev `/run` |
| **P5 Real-hardware gate + flip** | Differential run against the CR presses read-only (VPN from the Mac); a perf comparison (round-trips + p50/p95 poll latency for one press's tag set, native vs libplctag); then **default ⇒ `Native` for both drivers** | AbCip: diff clean on browse / templates / reads, no worse latency, Forward Open count during the run = 1. AbLegacy: simulator proof only, **recorded as a known gap** |
| **P6 Delete libplctag** *(conditional)* | Remove the package, adapters and `Libplctag*` members | **Blocked until a bench controller** proves writes (CompactLogix or Micro800) and PCCC (SLC / MicroLogix). Until then libplctag stays selectable |
### Real-hardware constraints (from `~/Desktop/ignitionoee`)
- **Reachable targets:** 20 × CompactLogix **5069-L320ERS2 rev 34.11** presses at CR
(Z-35162..Z-35181; Z-35162 = `10.209.70.160:44818`), reachable from the Mac over VPN. This is
the same rig `cc876bdf` verified against.
- **They are production machines and strictly read-only.** P5 performs **no writes**, opens a
single connection per press, and touches one press at a time.
- **No legacy PCCC hardware is reachable.** The CR presses were confirmed non-MicroLogix by
ListIdentity. The Delmia inventory lists PLC-5/04s at Warsaw with no addresses, and as far as I
know a PLC-5/04 has no built-in Ethernet port. There is no ControlLogix and no Micro800 either. So
routed paths beyond `1,0` and Micro800 UCMM-only mode are simulator-proven only.
## 9. Known gaps and risks
- **AbCip writes are simulator-proven only.** Mitigation: Write Tag shares framing with the
read-proven path. The risk sits in the encoding details (STRING struct handle, type codes), which
have golden tests and run against `ab_server`. libplctag stays selectable.
- **AbLegacy is simulator-proven only**, against `ab_server`'s PCCC emulation, which shares
libplctag's view of the protocol. This is the weakest evidence in the plan, stated plainly.
- **`ab_server` patches** (`0001` symbol listing, `0002` CIP 0x04 for not-found) mean the simulator is
already partly ours. A native/libplctag disagreement against it must be checked against the real
press before blaming either side.
- **The same-width type check changes client-visible quality** (§5).
- **Behaviour libplctag hides that we now own:** connection keep-alive, sequence-count wrap, the
session-registration lifetime, and the TCP reconnect after an idle teardown by a NAT or firewall.
Each one gets a scheduler/connection test.
@@ -0,0 +1,479 @@
# Native AB transport gate results
## ab_server suites (T17)
Every ab_server integration test now runs once per transport (`TransportMatrix` in each integration
project). Run 2026-10-07 against `10.100.0.35`, one compose profile at a time
(AbCip `/opt/otopcua-abcip`, `AB_SERVER_COMPOSE_PROFILE=<p>`; AbLegacy `~/otopcua-ablegacy`,
`AB_LEGACY_COMPOSE_PROFILE=<p>`); every stack was brought down afterwards.
- **Image:** `otopcua-ab-server:libplctag-release` (`sha256:19e814747d85…`, built 2026-08-01; ab_server from
libplctag `v2.7.0` + patches 0001/0002). Host compose files md5-identical to the repo's.
- **Commit:** fixes `82d011c7` + `2171fc8c`, test matrix `8c68694d`.
Counts are pass / fail / skip for the transport-parameterised rows only. Each AbCip run also passed the
6 transport-independent `AbServerProfileTests`. The AbCip skips are the 3 Emulate-tier tests
(`AB_SERVER_PROFILE=emulate` only). The AbLegacy skips are `Slc500_write_then_read…`, which skips itself
on both transports when the slc500 port (`:44819`) is down.
| Profile | Libplctag | Native | Native ⊇ Libplctag |
|---|---|---|---|
| controllogix | 1 / 0 / 3 | 1 / 0 / 3 | yes |
| compactlogix | 1 / 0 / 3 | 1 / 0 / 3 | yes |
| micro800 | 0 / 1 / 3 | 1 / 0 / 3 | yes (libplctag fails, see below) |
| guardlogix | 1 / 0 / 3 | 1 / 0 / 3 | yes |
| slc500 | 2 / 0 / 0 | 2 / 0 / 0 | yes |
| micrologix | 1 / 0 / 1 | 1 / 0 / 1 | yes |
| plc5 | 1 / 0 / 1 | 1 / 0 / 1 | yes |
**Native-only failures found and fixed:**
- `82d011c7` (controllogix): RegisterSession carried a non-zero sender context, and ab_server rejects that
with encapsulation status 1. Every native session failed.
- `2171fc8c` (slc500): ab_server answers Execute PCCC with a fixed requestor id
(`07 3D F3 45 43 50 21`), so the requestor-echo check rejected every native PCCC reply. Native now sends
libplctag's identity.
**Skips added:** none. The PLC-5 integration test only reads N7, so the PLC-5 Unsupported paths
(non-N reads, all writes) and status-bit sub-element writes are never exercised by the suite.
**micro800:** the smoke test used to send `/1,0` for every family, and both transports failed against
`--plc=Micro800`. It now uses an empty path for Micro800. With the empty path, native passes. libplctag
still fails with BadCommunicationError, because the driver's libplctag runtime passes `Path = ""` and
libplctag answers that with `ErrorBadData`. A `null` path works (checked with the capture client). That
defect is pre-existing and libplctag-side; it is recorded, not fixed, here.
## Differential vs ab_server (T18)
Run 2026-10-07 on branch `feat/native-ab-transport`, harness
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/`, against `ab_server`
(`controllogix` profile only, `/opt/otopcua-abcip` on 10.100.0.35):
```bash
AB_DIFF_ENDPOINT=10.100.0.35:44818 AB_DIFF_COUNT_OPENS=1 \
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests \
--filter "FullyQualifiedName~Differential" --logger "console;verbosity=detailed"
```
Target as the harness resolved it: `10.100.0.35:44818`, path `1,0`, family `CompactLogix`
(the defaults; libplctag attribute `compactlogix`, connection size 504).
**Result: 7/7 passed, 0 unexpected divergences, 0 expected divergences.**
| Check | Result |
|---|---|
| Browse parity (`@tags` + program listings) | 9 tags via libplctag, 9 via native, identical as multisets |
| Template parity | vacuous: ab_server serves no UDTs, so 0 template ids. Task 21's press is the first real exercise |
| Read parity | 9/9 tags equal on status, raw buffer and decoded value |
| Native Forward Opens over the run (`AB_DIFF_COUNT_OPENS=1`) | **1** |
| TCP connections to the target before the native phase | 0 (baseline 0). libplctag's session closed within 1 ms of the last handle being disposed |
| Read-only guard (offline, runs without the endpoint) | pass |
| Comparison falsifiability (offline) | pass |
| Tag | Type | libplctag status | native status | Equal? |
|---|---|---|---|---|
| `Motor_Run` | Bool | Ok | Ok | yes |
| `Line1_Count` | DInt | Ok | Ok | yes |
| `Pump_1_Speed` | Real | Ok | Ok | yes |
| `TestString` | String | Ok | Ok | yes |
| `TestSINT` | SInt | Ok | Ok | yes |
| `TestBOOL` | Bool | Ok | Ok | yes |
| `TestREAL` | Real | Ok | Ok | yes |
| `TestDINT` | DInt | Ok | Ok | yes |
| `TestArray` | DInt[16] | Ok | Ok | yes |
**The values were made non-zero first.** An all-zero simulator would make raw and value equality
vacuous. Before the run, the simulator was seeded through a throwaway libplctag scratch program
outside the repo. The harness itself never writes. Seeded values:
- `TestDINT` = −123456789
- `TestREAL` = 3.14159
- `Pump_1_Speed` = 1234.5
- `Line1_Count` = 987654
- `TestArray[i]` = (i−8)·100003+7, which includes negatives
- `TestString` = `"Hello T18"`
ab_server refuses 1-byte writes (`ErrorBadParam`), so `TestBOOL`, `TestSINT` and `Motor_Run` stay 0.
Their parity is therefore weaker evidence.
**Wire capture of the same run.** tcpdump ran in the ab_server container's network namespace
(`nicolaka/netshoot`), and the capture was decoded with tshark:
| TCP stream | Session | RegisterSession | Forward Open | Forward Close | Requests | Window (s, relative) |
|---|---|---|---|---|---|---|
| 12 | libplctag | 1 | 1 (Large, 0x5B) | 1 | 1× 0x55 listing, 18× 0x52 Read Fragmented | 0.243 → 0.336 |
| 13 | native | 1 | 1 (0x54) | 1 | 1× 0x55 listing, 18× 0x52 Read Fragmented | 0.349 → 0.452 |
- Exactly two sessions, with no overlap: a 13 ms gap between them.
- Zero write services on the wire: no 0x4D, 0x53 or 0x10.
- The 12 earlier sub-10 ms TCP connections are the `AbServerFact`/`AbServerTheory` reachability probes. xUnit runs them during discovery for the *other* suites, even under the filter. They target `AB_SERVER_ENDPOINT` (default 10.100.0.35), never `AB_DIFF_ENDPOINT`.
**Explicit-list path.** `AB_DIFF_TAGS="TestDINT,NoSuchTag,TestArray,TestString"`: 4/4 equal.
`NoSuchTag` returned `NotFound` under both transports, and there was still exactly one native Forward Open.
**Transport fixes found by the differential: none.** One harness defect was found and fixed before
this result. libplctag closes a session as soon as its last handle is destroyed. The first version of the
libplctag phase created, read and disposed one handle at a time, so it opened a fresh session (and a
fresh Forward Open) per tag: 9+ sessions, which the netstat sampling showed. The libplctag phase now holds an anchor handle
on `@tags` for its whole duration, which mirrors the native anchor lease. That leaves the one-session
capture above.
### Review round 1 re-run (press-safety fixes)
Re-run 2026-10-07 after the review-r1 fixes, with the default pacing. `AB_SERVER_ENDPOINT=127.0.0.1:1`
kept the sibling suites' discovery probes off the network:
```bash
AB_SERVER_ENDPOINT=127.0.0.1:1 AB_DIFF_ENDPOINT=10.100.0.35:44818 AB_DIFF_COUNT_OPENS=1 \
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests \
--filter "FullyQualifiedName~Differential" --logger "console;verbosity=detailed"
```
**Result: 12/12 passed — 7 live tests and 5 offline safety tests — with 0 unexpected and 0 expected divergences.**
| Check | Result |
|---|---|
| Settings | pace 20 ms, max tags 200, max templates 50 |
| Browse | 9 = 9 |
| Selection (array round-robin first) | 1 array + 8 scalars. ab_server has no UDTs, so there were no UDT members and the coverage check had nothing to require |
| Reads | 9/9 equal |
| Templates | 0 ids, not truncated. Not vacuous by the new rule: ab_server's `STRING` is not a structure, so browse found no structured tags |
| Native Forward Opens | **1** |
| TCP connections to the target before native | 0 (baseline 0) |
**Wire capture of this run** (tcpdump in the ab_server container's namespace, decoded with tshark):
| TCP stream | Session | Forward Open | Forward Close | Requests | Window (s) |
|---|---|---|---|---|---|
| 0 | libplctag | 1 (Large, 0x5B) | 1 | 1× 0x55, 18× 0x52 | 0.000 → 0.487 |
| 1 | native | 1 (0x54) | 1 | 1× 0x55, 18× 0x52 | 0.510 → 0.984 |
- No discovery probes.
- Zero write services: no 0x4D, 0x53, 0x4B or 0x10.
- The two sessions are 23 ms apart and do not overlap.
**The Forward Opens differ between the transports, and that is expected.** libplctag always uses a *Large*
Forward Open. Native uses a regular one because the `CompactLogix` profile's connection size (504) is at most 511. Task 21 will
see the same split on the press.
**What changed for press safety (review r1):**
- **Stop on the first connection-level failure, on either transport.** That covers a refused connect,
RegisterSession or Forward Open, a timeout, and a lost session. Any status other than
NotFound/NotAllowed/OutOfBounds/TooLarge/TooSmall/Unsupported/TypeMismatch aborts the whole run with the
failing operation and status. Nothing is retried.
- **The native phase is capped at one open attempt.** It sets the new process-wide
`CipConnection.OpenAttemptBudget`, so a reconnect cannot reach the wire at all, even from inside an
enumerator that swallows a per-program failure.
- **The run also aborts the moment `ForwardOpenCount` passes 1.** This is checked after every browse entry,
template and tag.
- **Offline pin:** against a loopback listener that accepts and hangs up, the native phase aborts at browse after exactly
**one** TCP connect. Removing the harness abort turns the test red. Removing both the abort and the budget
turns the connect count red.
- **Pacing:** `AB_DIFF_PACE_MS` (default 20) is applied before every harness-issued request: browse,
each template read, each tag's create-read and read. Listing *pages* inside the enumerators are not
paced.
- **Volume caps:** `AB_DIFF_MAX_TEMPLATES` defaults to 50. `AB_DIFF_MAX_TAGS` and `AB_DIFF_MAX_TEMPLATES` must be at most 500;
a higher value fails fast instead of being clamped.
- **Selection:** the default selection takes arrays (up to 20), UDT members (up to 20) and scalars round-robin, so any cap
of 3 or more exercises all three kinds. The reads test fails if browse offered arrays or UDT members
but the selection holds none.
- **Template parity** fails when browse found structured tags but no template id was compared, and treats an
undecodable shape on either side as a mismatch.
- **Write-capable integration tests** now require `AB_SERVER_ALLOW_WRITES=1` and refuse while
`AB_DIFF_ENDPOINT` is set. Those tests are the Logix Emulate ALMD test and the AbLegacy SLC500 N7:5 round trip.
The ab_server controllogix matrix is read-only and unaffected: 15 passed and 11 skipped with
`AB_SERVER_COMPOSE_PROFILE=controllogix`.
### Latency harness proven on ab_server (T21 Step 1)
`Differential/PollLatencyComparisonTests.cs` (+ `PollLatencyRun.cs`) ran 2026-10-07 against ab_server
(`controllogix` image, host port 44828 because an unrelated outbound socket held 44818 on the docker host),
under the same `--filter "FullyQualifiedName~Differential"` invocation as the differential. The mechanics work:
the timed phase reuses the differential's browse, the anchors, the open budget and the barriers; **1** native Forward Open
in the latency phase; 30 passes per transport, 0 tag failures; native packs 100 concurrent reads into **6** requests per pass.
ab_server has 7 atomic scalars, so the 100-tag set was **repeated cyclically** (reported in the output).
| Family | libplctag p50 / p95 ms | native p50 / p95 ms | native requests/pass | Criterion |
|---|---|---|---|---|
| CompactLogix (504, the press default) | 7.7 / 10.9 | 22.1 / 25.3 | 6 | **FAIL** |
| ControlLogix (4002) | 7.3 / 9.1 | 7.7 / 9.2 | 2 | pass |
The CompactLogix result was a real finding, not a harness fault. Native opened a 504-byte connection there, while libplctag
opened a Large one (see the T18 capture above), so native needed 3× the round trips per pass.
**Fixed:** the CompactLogix profile now defaults to 4002, so native opens Large like libplctag. A refusal still falls back to
a regular 504 open. Re-run 2026-10-07 23:30–23:32Z against the `compactlogix` compose profile on :44818 (3 distinct atomic
tags, repeated to 100), with a capture in the container's network namespace:
| Run | libplctag p50 / p95 ms | native p50 / p95 ms | native req/pass | Criterion |
|---|---|---|---|---|
| 1 | — | — | — | aborted in the latency libplctag phase (see below) |
| 2 | 7.6 / 9.6 | 7.5 / 10.8 | 2 | FAIL (limit 10.6) |
| 3 | 7.8 / 9.3 | 8.1 / 9.4 | 2 | PASS (limit 10.2) |
| 4 | 7.7 / 10.0 | 7.8 / 11.7 | 2 | FAIL (limit 11.1) |
| 5 | — | — | — | aborted in the latency libplctag phase |
The structural gap is gone: p50s are equal, and both transports now send 2 requests per pass. At ~10 ms the p95 margin is about
1 ms, which is within this network path's jitter, so the ab_server verdict varies from run to run.
Capture: every session opened with **0x5B (Large Forward Open)** and was accepted (0xDB, status 0). That covers 15 sessions:
four per complete invocation and three in the aborted run 5. Each session had one Forward Close (0x4E to Connection Manager 6/1).
There were **zero** 0x4D/0x53/0x4B. Both aborts (runs 1 and 5) were the simulator: libplctag's ~2 KB packed request was split
across TCP segments (1448 + 606), and ab_server answered with FIN. The harness aborted on the first connection-level failure,
as designed. A real controller reassembles TCP; ab_server does not.
The T21 press result is recorded under `### Pass 3 — 2026-10-08 02:27–02:30 UTC (HEAD `8e2dbc67`, after the final-review fixes; same settings as pass 2) — with packet capture
**17/17 passed.** Browse 3162 = 3162 tags; 20 UDT templates identical; 200 tags read with 0 unexpected divergences
(6 live); native 1 Forward Open, Forward Closes sent/acked 1/1; latency native p95 105.1 ms ≤ libplctag p95 197.3 ms
×1.10 = 217.1 ms → PASS. Raw output: [pass 3](2026-10-07-native-ab-transport-t21/t21-pass3.txt).
Packet capture (`tcpdump -i utun4 host 10.209.70.160 and port 44818`, analysed with TCP sequence reassembly):
| Session | Order | Window (s) | CIP services seen (requests) | Forward Close target |
|---|---|---|---|---|
| libplctag differential | 1 | 0.0 – 82.5 | 0x03, 0x4C, 0x52, 0x55, 0x5B ×1, 0x4E ×1 | Connection Manager 6/1 |
| native differential | 2 | 82.5 – 126.3 | 0x03, 0x4C, 0x52, 0x55, 0x5B ×1, 0x4E ×1 (+ UnRegisterSession) | Connection Manager 6/1 |
| libplctag latency | 3 | 126.5 – 152.4 | 0x0A (MSP), 0x52, 0x5B ×1, 0x4E ×1 | Connection Manager 6/1 |
| native latency | 4 | 152.4 – 176.5 | 0x0A (MSP), 0x52, 0x5B ×1, 0x4E ×1 (+ UnRegisterSession) | Connection Manager 6/1 |
- **Zero write-class services** (no 0x4D Write Tag, 0x53 Write Tag Fragmented, 0x4B Execute PCCC, and no 0x4E to
anything but the Connection Manager).
- **Four sessions, strictly sequential** — each starts after the previous one's last packet; never two at once.
- Every session opened exactly one Large Forward Open and closed it with one Forward Close.
## Latency (T21)` and is not filled in here.
## AdminUI /run (T19)
Run 2026-10-07 on the local docker-dev rig (OrbStack), against the ab_server `controllogix` fixture
on `10.100.0.35:44818`. Every container ran the same freshly built image
(`otopcua-host:dev` `sha256:91bffbb0…`, checked per container because `:9200` round-robins). The
modal was driven with the Chrome tools against `http://localhost:9200/raw`.
**Authoring (UI).**
- Created the AbCip driver `t19-abcip` (`DRV-8ea0f0a8cfde`) in the MAIN cluster.
- Configure → Transport = "Native (managed)" → Save. `DriverInstance.DriverConfig` then held
`"transport":"Native"`, a string.
- Reopened Configure. The dropdown showed "Native (managed)". Changed only Timeout from 2 to 3 and
saved. The row then held `"transport":"Native"` and `"timeout":"00:00:03"`, so the save-reverts-transport
defect is fixed live, not only in unit tests.
- Created device `Device1plc1` with host `ab://10.100.0.35:44818/1,0` (ControlLogix). Test Connect
returned `OK · 62 ms`.
- Added the raw tag `TestDINT` (Int32, read-only, tag path `TestDINT`, DInt).
- Seeded `TestDINT = 4242` on the simulator so a non-zero value proves the read path.
- Raw namespace: `ns=2`, the shared `s=OtOpcUa` root plus RawPath ids. UNS uses `ns=3`.
- RawPath: `t19-abcip/Device1plc1/TestDINT`.
**Leg 1: Native.** The deploy sealed with `acks=6`. Both central nodes logged:
```
AbCip driver DRV-8ea0f0a8cfde initializing: transport=Native tagFactory=NativeAbCipTagFactory
```
CLI read on central-1 (`:4840`) and central-2 (`:4841`):
```
Node: ns=2;s=t19-abcip/Device1plc1/TestDINT
Value: 4242
Status: 0x00000000
```
The simulator value was then changed to 5150 through the libplctag CLI. The native-served node
followed it (`Value: 5150`, `Status: 0x00000000`, source time `23:02:09.60Z`).
**Leg 2: inverted gate (Native, dead port).**
- Device host changed to `ab://10.100.0.35:44819/1,0`. Nothing listens on 44819
(`ss -ltn` count 0).
- The node went `BadNoCommunication` (value retained). A CLI read failed with
`ServiceResultException: [80050000]` (BadCommunicationError). The CLI `read` throws on a Bad
status rather than printing it.
- The central-1 log showed the native error text, raised from `NativeAbCipTagRuntime.ReadAsync`:
```
ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbTransportException: TCP connect to 10.100.0.35:44819 failed: ConnectionRefused
---> System.Net.Sockets.SocketException (111): Connection refused
```
- For contrast, the same dead port under libplctag logged different wording:
```
ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbTransportException: ErrorTimeout
---> libplctag.LibPlcTagException: ErrorTimeout
```
So the switch changes behaviour and is not inert.
**Leg 3: back to libplctag.**
- Device restored to port 44818. Configure → Transport = "libplctag (default)" → Save. The row held
`"transport":"Libplctag"`.
- The redeploy sealed with `acks=6`. Both centrals logged
`initializing: transport=Libplctag tagFactory=LibplctagTagFactory`.
- CLI reads on `:4840` and `:4841`: `Value: 5150`, `Status: 0x00000000`.
**Fix found by the gate.** The first deploy logged no transport line. `AbCipDriverFactoryExtensions.Register`
took no `ILoggerFactory`, and `DriverFactoryBootstrap` registered AbCip without one, so every production
AbCip driver ran on `NullLogger`. That hid the new line and every existing AbCip warning or error. AbCip
now takes the host logger factory the same way AbLegacy and Modbus do. `AbCipTransportInitLogTests` is
falsified by reverting that wiring: 2/2 tests fail.
**Cleanup.**
- The `t19-abcip` driver, device and tag rows were deleted and the change redeployed (sealed,
`acks=6`). Both centrals logged `stopped driver child DRV-8ea0f0a8cfde`.
- The rig has no test driver pointing at the fixture.
- The fixture was brought down (`docker compose --profile controllogix down`).
- The simulator's `TestDINT` was left at 5150 until teardown. The container is gone, so the value
does not persist.
## Real press (T21)
Target: **Z-35162**, production CompactLogix 5069-L320ERS2, `10.209.70.160:44818`, path `1,0`, family CompactLogix
(native connection size 4002, Large Forward Open). Read-only. User approved the press and each time window; VPN up;
no other client on this Mac connected to the press. Driven from the Mac over VPN (≈44 ms RTT).
### Pass 1 — 2026-10-07 23:35–23:36 UTC (HEAD `c306c6f6`; 25 tags, 20 templates, pace 50 ms, latency 100×30)
12/14 passed. **Safety held**: packet capture (`tcpdump` on `utun4`) shows 4 sequential sessions, each one Large
Forward Open (0x5B) and one Forward Close (0x4E, Connection Manager 6/1, acked); services seen only 0x03, 0x0A, 0x4C,
0x52, 0x55, 0x5B, 0x4E; **zero writes** (no 0x4D/0x53/0x4B). Browse parity and all 20 UDT template shapes identical;
native Forward Opens = 1.
Failures, and what they led to:
- **Read parity, 2 tags** — live analog values moved between the sequential libplctag and native phases
(`WW_M4ActualTemp` 384.887 → 385.138). Harness defect: it could not tell drift from divergence. Fixed by a volatility
probe (`82a0c298`, tightened element-wise in `43b81b66`): a value that moves between two libplctag reads is
compared by status, length, type, shape and **every element that held still**.
- **Latency** — native p95 142.8 ms vs libplctag 99.1 ms ×1.10 = 109.1 ms (FAIL), medians native 93.7 / libplctag
95.2 ms; three native outliers in 30 passes. Criterion unchanged; pass count raised to 50 and per-pass GC/RTT
diagnostics added.
- **Test host crashed at exit** — `A callback was made on a garbage collected delegate … callback_func_ex`. Root cause
is in libplctag.NET 1.5.2: a failed tag create leaves its native callback registered and `Dispose()` doesn't release
it. **Production-reachable** on the default transport (a nonexistent tag name crashes the server node at
stop/restart). Fixed in the adapters (`6cd68762`, hardened `6ae62873`); Gitea #578.
- Found before pass 1, on ab_server: native CompactLogix opened a 504-byte connection (the family default libplctag
never honoured) and needed ~3× the round-trips. Native default changed to 4002 with the existing Large→regular
fallback (`c306c6f6`).
### Pass 2 — 2026-10-08 00:21–00:24 UTC (HEAD `df776170`, fresh build; 200 tags, 20 templates, pace 50 ms, latency 100×50)
**17/17 passed.**
| Check | Result |
|---|---|
| Browse parity | libplctag 3162 tags, native 3162 — identical sets |
| UDT templates | 20 compared (of 74 found, capped) — identical shapes |
| Read parity | 200 tags: **0 unexpected**, 0 expected (TypeMismatch) divergences; 6 live tags (below) |
| Native Forward Opens | 1 per native phase |
| Native Forward Closes sent/acked | 1/1 per native phase; 0 connections left |
| Sessions | libplctag and native never connected at the same time |
| Writes | none — read-only source guard passes; `grep -c WriteAsync` = 0 over every Differential file |
| libplctag failed-create orphans | 15 destroyed by the #578 fix, 0 rooted — no crash at exit |
⚠️ **Pass 2 has no independent packet capture** (pass 3 below re-ran it with one): the operator's `tcpdump` had stopped at 23:45 UTC, before pass 2 began,
and this was not noticed until afterwards. The read-only evidence for pass 2 is the source guard and the harness's
own Forward Open/Close counters; pass 1's capture is the wire-level proof for the same code paths.
Live tags (value moved between the two libplctag reads; every element that held still matched native):
```
- live: WW_M4ActualTemp (lib 4 byte(s) 0CCDC243 = 389.60193; lib probe 4 byte(s) 7084C243 = 389.03467; native 4 byte(s) 4C47C243 = 388.557)
- live: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5 (lib 20 byte(s) 0050C348218986402189A640DF76D9C021898640 = [400000,4.2042394,5.2042394,-6.7957606,4.2042394]; lib probe 20 byte(s) 0050C348E74B8640E74BA64019B4D9C0E74B8640 = [400000,4.1967654,5.1967654,-6.8032346,4.1967654]; native 20 byte(s) 0050C348BB328640BB32A64045CDD9C0BB328640 = [400000,4.1936927,5.1936927,-6.8063073,4.1936927])
- live: Program:EM02_Clamp.__fout2F67CD35C2302EE0 (lib 16 byte(s) BFB2BF379A9919BF00D68349CDCCCCBE = [2.285221E-05,-0.6,1080000,-0.4]; lib probe 16 byte(s) 5119313A9A9919BF00D68349CDCCCCBE = [0.00067557866,-0.6,1080000,-0.4]; native 16 byte(s) 1562C2B99A9919BF00D68349CDCCCCBE = [-0.0003707564,-0.6,1080000,-0.4])
- live: Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB (lib 16 byte(s) 00E14CC0CDDA75BF000000C08F2D2BC0 = [-3.201233,-0.9603699,-2,-2.6746557]; lib probe 16 byte(s) 009464C09A2589BF000000C0A84932C0 = [-3.5715332,-1.07146,-2,-2.7857456]; native 16 byte(s) 00A03AC034F35FBF000000C0A8B325C0 = [-2.9160156,-0.87480474,-2,-2.5890903])
- live: Program:ES03_EM01_Zone1.__fout92848E133B44AF24 (lib 32 byte(s) 40EDA94160F0D043000090C10022A14980256C41C656D143006A4F400000A8C1 = [21.240845,417.87793,-18,1320000,14.759155,418.67792,3.2408447,-21]; lib probe 32 byte(s) 6017AD412089D043000090C10022A14940D1654186EFD04300BB68400000A8C1 = [21.636414,417.0713,-18,1320000,14.363586,417.87128,3.6364136,-21]; native 32 byte(s) C047A841BE97CF43000090C10022A14980706F4124FECF43003E42400000A8C1 = [21.035034,415.1855,-18,1320000,14.964966,415.98547,3.0350342,-21])
- live: Program:ES03_EM01_Zone1.__fout6DC7745628658A9D (lib 8 byte(s) 00824E40CD34A53F = [3.2266846,1.2906739]; lib probe 8 byte(s) 008A6440CDD4B63F = [3.5709229,1.4283692]; native 8 byte(s) 000D3940670A943F = [2.8914185,1.1565675])
```
Test summary:
```
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [26 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [17 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [4 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [15 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [3 m 2 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_closes_its_one_connection_with_exactly_one_forward_close [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.No_failed_libplctag_tag_is_left_with_a_live_native_callback [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.A_live_value_is_compared_structurally_and_a_stable_one_by_value [3 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [36 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [3 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
Total tests: 17
Passed: 17
```
Full raw `dotnet test` output: [pass 1](2026-10-07-native-ab-transport-t21/t21-pass1.txt), [pass 2](2026-10-07-native-ab-transport-t21/t21-pass2.txt).
## Latency (T21)
Pass 2 (100 atomic tags × 50 passes per transport, 200 ms gap, all reads in a pass issued concurrently):
| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
|---|---|---|---|---|---|---|---|
| libplctag | 50 | 107.0 | 139.5 | 57.9 | 164.8 | n/a | 0 |
| native | 50 | 100.3 | 105.8 | 94.8 | 107.5 | 2 | 0 |
**Criterion: native p95 105.8 ms ≤ libplctag p95 139.5 ms × 1.10 = 153.4 ms → PASS.** Native was faster at the median
and much steadier (max 107.5 ms vs 164.8 ms), packing 95 reads into 2 CIP requests per pass. Five pad/config tags that
libplctag refused at setup were removed from both sets (`Local:*:I.Pad1`, `Local:5:C.CfgSize`, `Local:5:C.CfgIDNum`).
Pass 1 (30 passes) failed this criterion on three native outliers (p95 142.8 vs limit 109.1; medians native 93.7 /
libplctag 95.2) — see above.
Per-pass detail (pass 2):
```
| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |
|---|---|---|---|---|---|---|
| 1 | 104.8 | 0/0/0 | 100.3 | 0/0/0 | 2 | 44.9/49.6/54.4 |
| 2 | 57.9 | 0/0/0 | 100.5 | 0/0/0 | 2 | 47.0/50.1/53.3 |
| 3 | 139.5 | 0/0/0 | 105.8 | 0/0/0 | 2 | 45.3/52.9/60.4 |
| 4 | 104.5 | 0/0/0 | 100.6 | 1/0/0 | 2 | 47.3/50.2/53.2 |
| 5 | 108.0 | 0/0/0 | 105.3 | 0/0/0 | 2 | 44.5/52.6/60.6 |
| 6 | 104.4 | 0/0/0 | 105.6 | 0/0/0 | 2 | 44.2/52.7/61.2 |
| 7 | 60.8 | 0/0/0 | 98.7 | 0/0/0 | 2 | 44.3/49.3/54.2 |
| 8 | 59.6 | 0/0/0 | 102.6 | 0/0/0 | 2 | 47.4/51.2/55.0 |
| 9 | 109.2 | 0/0/0 | 97.0 | 0/0/0 | 2 | 45.9/48.4/50.9 |
| 10 | 109.7 | 0/0/0 | 99.3 | 0/0/0 | 2 | 45.9/49.6/53.3 |
| 11 | 105.9 | 0/0/0 | 103.0 | 0/0/0 | 2 | 46.2/51.4/56.7 |
| 12 | 107.7 | 0/0/0 | 95.1 | 0/0/0 | 2 | 43.3/47.5/51.7 |
| 13 | 104.7 | 0/0/0 | 104.0 | 0/0/0 | 2 | 45.7/52.0/58.2 |
| 14 | 106.8 | 0/0/0 | 96.5 | 0/0/0 | 2 | 43.6/48.2/52.7 |
| 15 | 107.2 | 0/0/0 | 102.4 | 0/0/0 | 2 | 46.1/51.1/56.2 |
| 16 | 105.5 | 0/0/0 | 97.4 | 0/0/0 | 2 | 44.7/48.6/52.6 |
| 17 | 105.9 | 0/0/0 | 107.5 | 0/0/0 | 2 | 51.0/53.7/56.4 |
| 18 | 103.2 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.0/48.2/53.5 |
| 19 | 106.5 | 0/0/0 | 102.1 | 0/0/0 | 2 | 45.5/51.0/56.4 |
| 20 | 106.8 | 0/0/0 | 99.6 | 0/0/0 | 2 | 44.6/49.7/54.9 |
| 21 | 122.8 | 0/0/0 | 104.5 | 0/0/0 | 2 | 52.1/52.2/52.3 |
| 22 | 109.8 | 0/0/0 | 104.3 | 0/0/0 | 2 | 45.8/52.1/58.5 |
| 23 | 104.3 | 0/0/0 | 98.6 | 0/0/0 | 2 | 45.4/49.2/53.0 |
| 24 | 109.7 | 0/0/0 | 98.8 | 0/0/0 | 2 | 45.9/49.3/52.8 |
| 25 | 155.1 | 0/0/0 | 101.0 | 0/0/0 | 2 | 45.5/50.4/55.4 |
| 26 | 164.8 | 0/0/0 | 102.9 | 0/0/0 | 2 | 43.9/51.4/58.8 |
| 27 | 120.4 | 0/0/0 | 101.4 | 0/0/0 | 2 | 48.2/50.7/53.1 |
| 28 | 108.6 | 0/0/0 | 101.7 | 0/0/0 | 2 | 46.9/50.8/54.7 |
| 29 | 106.0 | 0/0/0 | 98.5 | 0/0/0 | 2 | 45.5/49.2/52.9 |
| 30 | 102.9 | 0/0/0 | 94.9 | 0/0/0 | 2 | 43.6/47.4/51.2 |
| 31 | 111.3 | 0/0/0 | 99.9 | 0/0/0 | 2 | 45.6/49.9/54.2 |
| 32 | 103.3 | 0/0/0 | 96.4 | 0/0/0 | 2 | 43.6/48.1/52.6 |
| 33 | 105.8 | 0/0/0 | 104.6 | 0/0/0 | 2 | 46.4/52.2/58.0 |
| 34 | 109.3 | 0/0/0 | 95.8 | 0/0/0 | 2 | 43.2/47.8/52.4 |
| 35 | 110.2 | 0/0/0 | 102.1 | 0/0/0 | 2 | 46.3/51.0/55.7 |
| 36 | 108.0 | 0/0/0 | 101.7 | 0/0/0 | 2 | 47.5/50.8/54.0 |
| 37 | 107.6 | 0/0/0 | 96.7 | 0/0/0 | 2 | 45.0/48.3/51.5 |
| 38 | 108.9 | 0/0/0 | 96.5 | 0/0/0 | 2 | 44.7/48.2/51.6 |
| 39 | 105.2 | 0/0/0 | 98.2 | 0/0/0 | 2 | 45.6/49.0/52.5 |
| 40 | 106.7 | 0/0/0 | 99.4 | 0/0/0 | 2 | 44.4/49.6/54.9 |
| 41 | 107.0 | 0/0/0 | 101.8 | 0/0/0 | 2 | 46.9/50.8/54.8 |
| 42 | 108.6 | 0/0/0 | 106.7 | 0/0/0 | 2 | 49.2/53.3/57.4 |
| 43 | 107.0 | 0/0/0 | 102.6 | 0/0/0 | 2 | 49.9/51.2/52.6 |
| 44 | 107.7 | 0/0/0 | 100.3 | 0/0/0 | 2 | 47.4/50.1/52.7 |
| 45 | 109.5 | 0/0/0 | 102.6 | 0/0/0 | 2 | 45.8/51.2/56.7 |
| 46 | 107.6 | 0/0/0 | 104.0 | 0/0/0 | 2 | 48.4/52.0/55.5 |
| 47 | 115.6 | 0/0/0 | 99.0 | 0/0/0 | 2 | 45.9/49.4/52.9 |
| 48 | 106.0 | 0/0/0 | 97.8 | 0/0/0 | 2 | 44.9/48.8/52.8 |
| 49 | 104.5 | 0/0/0 | 98.4 | 0/0/0 | 2 | 46.1/49.1/52.2 |
| 50 | 112.5 | 0/0/0 | 94.8 | 1/1/1 | 2 | 43.0/47.3/51.7 |
```
@@ -0,0 +1,307 @@
Test run for /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.1 (arm64)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.0.2+dd36e86129 (64-bit .NET 10.0.5)
[xUnit.net 00:00:00.33] Discovering: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
[xUnit.net 00:00:00.46] Discovered: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
[xUnit.net 00:00:00.57] Starting: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [11 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [7 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [2 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
Standard Output Messages:
libplctag connects before the abort: 1
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [13 ms]
[xUnit.net 00:01:15.30] ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [FAIL]
[xUnit.net 00:01:15.30] Shouldly.ShouldAssertException : natP95
[xUnit.net 00:01:15.30] should be less than or equal to
[xUnit.net 00:01:15.30] 109.06115000000001d
[xUnit.net 00:01:15.30] but was
[xUnit.net 00:01:15.30] 142.765d
[xUnit.net 00:01:15.30]
[xUnit.net 00:01:15.30] Additional Info:
[xUnit.net 00:01:15.30] native p95 must be ≤ libplctag p95 × 1.10
[xUnit.net 00:01:15.30] Stack Trace:
[xUnit.net 00:01:15.30] /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs(74,0): at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag()
[xUnit.net 00:01:15.30] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
[xUnit.net 00:01:15.30] --- End of stack trace from previous location ---
[xUnit.net 00:01:15.30] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
[xUnit.net 00:01:15.30] Output:
[xUnit.net 00:01:15.30] ## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
[xUnit.net 00:01:15.30] Tags per pass: 95 (100 distinct); passes per transport: 30; gap between passes: 200 ms.
[xUnit.net 00:01:15.30] Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
[xUnit.net 00:01:15.30] Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
[xUnit.net 00:01:15.30] Native requests spent on setup (one read per tag): 95; native Forward Opens: 1.
[xUnit.net 00:01:15.30]
[xUnit.net 00:01:15.30] | Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
[xUnit.net 00:01:15.30] |---|---|---|---|---|---|---|---|
[xUnit.net 00:01:15.30] | libplctag | 30 | 95.2 | 99.1 | 90.8 | 179.2 | n/a | 0 |
[xUnit.net 00:01:15.30] | native | 30 | 93.7 | 142.8 | 88.8 | 146.1 | 2 | 0 |
[xUnit.net 00:01:15.30]
[xUnit.net 00:01:15.30] Criterion: native p95 142.8 ms ≤ libplctag p95 99.1 ms × 1.10 = 109.1 ms → **FAIL**
[xUnit.net 00:01:15.30]
[xUnit.net 00:01:15.30] | Pass | libplctag ms | native ms | native requests |
[xUnit.net 00:01:15.30] |---|---|---|---|
[xUnit.net 00:01:15.30] | 1 | 95.0 | 95.4 | 2 |
[xUnit.net 00:01:15.30] | 2 | 94.4 | 92.4 | 2 |
[xUnit.net 00:01:15.30] | 3 | 95.8 | 96.8 | 2 |
[xUnit.net 00:01:15.30] | 4 | 95.0 | 94.7 | 2 |
[xUnit.net 00:01:15.30] | 5 | 98.8 | 93.7 | 2 |
[xUnit.net 00:01:15.30] | 6 | 92.4 | 101.0 | 2 |
[xUnit.net 00:01:15.30] | 7 | 94.1 | 94.1 | 2 |
[xUnit.net 00:01:15.30] | 8 | 97.0 | 96.7 | 2 |
[xUnit.net 00:01:15.30] | 9 | 93.4 | 91.2 | 2 |
[xUnit.net 00:01:15.30] | 10 | 97.7 | 91.0 | 2 |
[xUnit.net 00:01:15.30] | 11 | 94.6 | 91.1 | 2 |
[xUnit.net 00:01:15.30] | 12 | 96.5 | 92.0 | 2 |
[xUnit.net 00:01:15.30] | 13 | 94.1 | 97.6 | 2 |
[xUnit.net 00:01:15.30] | 14 | 96.1 | 94.0 | 2 |
[xUnit.net 00:01:15.30] | 15 | 90.8 | 93.3 | 2 |
[xUnit.net 00:01:15.30] | 16 | 95.6 | 93.7 | 2 |
[xUnit.net 00:01:15.30] | 17 | 93.9 | 100.6 | 2 |
[xUnit.net 00:01:15.30] | 18 | 92.4 | 121.6 | 2 |
[xUnit.net 00:01:15.30] | 19 | 97.5 | 92.7 | 2 |
[xUnit.net 00:01:15.30] | 20 | 95.2 | 93.8 | 2 |
[xUnit.net 00:01:15.30] | 21 | 96.8 | 90.4 | 2 |
[xUnit.net 00:01:15.30] | 22 | 98.6 | 99.1 | 2 |
[xUnit.net 00:01:15.30] | 23 | 92.1 | 146.1 | 2 |
[xUnit.net 00:01:15.30] | 24 | 99.1 | 142.8 | 2 |
[xUnit.net 00:01:15.30] | 25 | 96.6 | 91.7 | 2 |
[xUnit.net 00:01:15.30] | 26 | 96.9 | 91.4 | 2 |
[xUnit.net 00:01:15.30] | 27 | 179.2 | 92.0 | 2 |
[xUnit.net 00:01:15.30] | 28 | 98.5 | 91.7 | 2 |
[xUnit.net 00:01:15.30] | 29 | 95.0 | 88.8 | 2 |
[xUnit.net 00:01:15.30] | 30 | 95.0 | 93.4 | 2 |
Failed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [1 m 6 s]
Error Message:
Shouldly.ShouldAssertException : natP95
should be less than or equal to
109.06115000000001d
but was
142.765d
Additional Info:
native p95 must be ≤ libplctag p95 × 1.10
Stack Trace:
at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag() in /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs:line 74
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
--- End of stack trace from previous location ---
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
Standard Output Messages:
## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
Tags per pass: 95 (100 distinct); passes per transport: 30; gap between passes: 200 ms.
Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
Native requests spent on setup (one read per tag): 95; native Forward Opens: 1.
| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
|---|---|---|---|---|---|---|---|
| libplctag | 30 | 95.2 | 99.1 | 90.8 | 179.2 | n/a | 0 |
| native | 30 | 93.7 | 142.8 | 88.8 | 146.1 | 2 | 0 |
Criterion: native p95 142.8 ms ≤ libplctag p95 99.1 ms × 1.10 = 109.1 ms → **FAIL**
| Pass | libplctag ms | native ms | native requests |
|---|---|---|---|
| 1 | 95.0 | 95.4 | 2 |
| 2 | 94.4 | 92.4 | 2 |
| 3 | 95.8 | 96.8 | 2 |
| 4 | 95.0 | 94.7 | 2 |
| 5 | 98.8 | 93.7 | 2 |
| 6 | 92.4 | 101.0 | 2 |
| 7 | 94.1 | 94.1 | 2 |
| 8 | 97.0 | 96.7 | 2 |
| 9 | 93.4 | 91.2 | 2 |
| 10 | 97.7 | 91.0 | 2 |
| 11 | 94.6 | 91.1 | 2 |
| 12 | 96.5 | 92.0 | 2 |
| 13 | 94.1 | 97.6 | 2 |
| 14 | 96.1 | 94.0 | 2 |
| 15 | 90.8 | 93.3 | 2 |
| 16 | 95.6 | 93.7 | 2 |
| 17 | 93.9 | 100.6 | 2 |
| 18 | 92.4 | 121.6 | 2 |
| 19 | 97.5 | 92.7 | 2 |
| 20 | 95.2 | 93.8 | 2 |
| 21 | 96.8 | 90.4 | 2 |
| 22 | 98.6 | 99.1 | 2 |
| 23 | 92.1 | 146.1 | 2 |
| 24 | 99.1 | 142.8 | 2 |
| 25 | 96.6 | 91.7 | 2 |
| 26 | 96.9 | 91.4 | 2 |
| 27 | 179.2 | 92.0 | 2 |
| 28 | 98.5 | 91.7 | 2 |
| 29 | 95.0 | 88.8 | 2 |
| 30 | 95.0 | 93.4 | 2 |
[xUnit.net 00:01:15.35] ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [FAIL]
[xUnit.net 00:01:15.35] Shouldly.ShouldAssertException : unexpected
[xUnit.net 00:01:15.35] should be empty but had
[xUnit.net 00:01:15.35] 2
[xUnit.net 00:01:15.35] items and was
[xUnit.net 00:01:15.35] ["WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)", "Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])"]
[xUnit.net 00:01:15.35] Stack Trace:
[xUnit.net 00:01:15.35] /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs(123,0): at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports()
[xUnit.net 00:01:15.35] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
[xUnit.net 00:01:15.35] --- End of stack trace from previous location ---
[xUnit.net 00:01:15.35] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
[xUnit.net 00:01:15.35] Output:
[xUnit.net 00:01:15.35] ## Reads — 25 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
[xUnit.net 00:01:15.35] Selection by origin: array 9, udt member 8, scalar 8
[xUnit.net 00:01:15.35] | Tag | Type | libplctag status | native status | Equal? |
[xUnit.net 00:01:15.35] |---|---|---|---|---|
[xUnit.net 00:01:15.35] | `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:1:I.InfoBits` | SInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `__CONTAINER` | DInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:1:I.RunMode` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `WW_M4ActualTemp` | Real | Ok | Ok | **NO: raw** |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `WW_P1_Status_Msg` | DInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **NO: raw** |
[xUnit.net 00:01:15.35] | `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `DI_CycleStart_PB` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `test` | DInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:1:I.Pad1` | Int | NotFound | NotFound | yes |
[xUnit.net 00:01:15.35] | `UN01_EventsPTR` | DInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:2:I.InfoBits` | SInt | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Sts_Vacuum_Rerun` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Local:2:I.RunMode` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Ons_VacuumRerun` | Bool | Ok | Ok | yes |
[xUnit.net 00:01:15.35] | `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | yes |
[xUnit.net 00:01:15.35]
[xUnit.net 00:01:15.35] Unexpected divergences: 2; expected divergences: 0.
[xUnit.net 00:01:15.35] - UNEXPECTED: WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)
[xUnit.net 00:01:15.35] - UNEXPECTED: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])
[xUnit.net 00:01:15.35] Finished: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
Process terminated.
A callback was made on a garbage collected delegate of type 'libplctag.NativeImport!libplctag.NativeImport.plctag+callback_func_ex::Invoke'.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [31 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
Standard Output Messages:
## Browse — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
libplctag: 3162 tag(s); native: 3162 tag(s)
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
Standard Output Messages:
## Templates — 20 id(s); truncated by AB_DIFF_MAX_TEMPLATES: libplctag 74, native 74
| Template | libplctag | native | Equal? |
|---|---|---|---|
| 283 | Recipe (5 members, 1680 B) | Recipe (5 members, 1680 B) | yes |
| 295 | UDT_PartStatus (5 members, 4 B) | UDT_PartStatus (5 members, 4 B) | yes |
| 344 | Recipe_Clamp (7 members, 420 B) | Recipe_Clamp (7 members, 420 B) | yes |
| 491 | ForceSegmentEnableCheck (3 members, 4 B) | ForceSegmentEnableCheck (3 members, 4 B) | yes |
| 527 | Recipe_PowerUnit (6 members, 24 B) | Recipe_PowerUnit (6 members, 24 B) | yes |
| 622 | UDT_TemperatureProfile_Segment (11 members, 40 B) | UDT_TemperatureProfile_Segment (11 members, 40 B) | yes |
| 676 | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | yes |
| 700 | AB:5000_AI4CJ:I:0 (12 members, 68 B) | AB:5000_AI4CJ:I:0 (12 members, 68 B) | yes |
| 706 | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | yes |
| 715 | TwoHand_Control (8 members, 16 B) | TwoHand_Control (8 members, 16 B) | yes |
| 752 | STR0016 (2 members, 20 B) | STR0016 (2 members, 20 B) | yes |
| 842 | STR0032 (2 members, 36 B) | STR0032 (2 members, 36 B) | yes |
| 862 | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | yes |
| 1129 | raM_Tec_LimitWithModulo (38 members, 120 B) | raM_Tec_LimitWithModulo (38 members, 120 B) | yes |
| 1144 | PartStatus (8 members, 12 B) | PartStatus (8 members, 12 B) | yes |
| 1166 | AB:5000_DI16:C:0 (18 members, 72 B) | AB:5000_DI16:C:0 (18 members, 72 B) | yes |
| 1192 | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | yes |
| 1206 | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | yes |
| 1289 | TimeString (12 members, 376 B) | TimeString (12 members, 376 B) | yes |
| 1313 | raM_Dvc_5000AO4 (73 members, 164 B) | raM_Dvc_5000AO4 (73 members, 164 B) | yes |
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
Standard Output Messages:
Native Forward Opens over the run: 1.
Failed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [2 ms]
Error Message:
Shouldly.ShouldAssertException : unexpected
should be empty but had
2
items and was
["WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)", "Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])"]
Stack Trace:
at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports() in /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs:line 123
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
--- End of stack trace from previous location ---
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
Standard Output Messages:
## Reads — 25 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
Selection by origin: array 9, udt member 8, scalar 8
| Tag | Type | libplctag status | native status | Equal? |
|---|---|---|---|---|
| `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | yes |
| `Local:1:I.InfoBits` | SInt | Ok | Ok | yes |
| `__CONTAINER` | DInt | Ok | Ok | yes |
| `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | yes |
| `Local:1:I.RunMode` | Bool | Ok | Ok | yes |
| `WW_M4ActualTemp` | Real | Ok | Ok | **NO: raw** |
| `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | yes |
| `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | yes |
| `WW_P1_Status_Msg` | DInt | Ok | Ok | yes |
| `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **NO: raw** |
| `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | yes |
| `DI_CycleStart_PB` | Bool | Ok | Ok | yes |
| `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | yes |
| `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | yes |
| `test` | DInt | Ok | Ok | yes |
| `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | yes |
| `Local:1:I.Pad1` | Int | NotFound | NotFound | yes |
| `UN01_EventsPTR` | DInt | Ok | Ok | yes |
| `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | yes |
| `Local:2:I.InfoBits` | SInt | Ok | Ok | yes |
| `Sts_Vacuum_Rerun` | Bool | Ok | Ok | yes |
| `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | yes |
| `Local:2:I.RunMode` | Bool | Ok | Ok | yes |
| `Ons_VacuumRerun` | Bool | Ok | Ok | yes |
| `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | yes |
Unexpected divergences: 2; expected divergences: 0.
- UNEXPECTED: WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)
- UNEXPECTED: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
Standard Output Messages:
TCP connections to the target: baseline 0, before native 0, libplctag drain wait 5284 ms.
Test Run Failed.
Total tests: 14
Passed: 12
Failed: 2
Total time: 1.2601 Minutes
@@ -0,0 +1,369 @@
Test run for /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.1 (arm64)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.0.2+dd36e86129 (64-bit .NET 10.0.5)
[xUnit.net 00:00:00.46] Discovering: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
[xUnit.net 00:00:00.70] Discovered: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
[xUnit.net 00:00:00.93] Starting: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [26 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [17 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [4 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
Standard Output Messages:
libplctag connects before the abort: 1
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [15 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [3 m 2 s]
Standard Output Messages:
## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
Tags per pass: 95 (100 distinct); passes per transport: 50; gap between passes: 200 ms.
Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
Native requests spent on setup (one read per tag): 95; native Forward Opens: 1; native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
|---|---|---|---|---|---|---|---|
| libplctag | 50 | 107.0 | 139.5 | 57.9 | 164.8 | n/a | 0 |
| native | 50 | 100.3 | 105.8 | 94.8 | 107.5 | 2 | 0 |
Criterion: native p95 105.8 ms ≤ libplctag p95 139.5 ms × 1.10 = 153.4 ms → PASS
| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |
|---|---|---|---|---|---|---|
| 1 | 104.8 | 0/0/0 | 100.3 | 0/0/0 | 2 | 44.9/49.6/54.4 |
| 2 | 57.9 | 0/0/0 | 100.5 | 0/0/0 | 2 | 47.0/50.1/53.3 |
| 3 | 139.5 | 0/0/0 | 105.8 | 0/0/0 | 2 | 45.3/52.9/60.4 |
| 4 | 104.5 | 0/0/0 | 100.6 | 1/0/0 | 2 | 47.3/50.2/53.2 |
| 5 | 108.0 | 0/0/0 | 105.3 | 0/0/0 | 2 | 44.5/52.6/60.6 |
| 6 | 104.4 | 0/0/0 | 105.6 | 0/0/0 | 2 | 44.2/52.7/61.2 |
| 7 | 60.8 | 0/0/0 | 98.7 | 0/0/0 | 2 | 44.3/49.3/54.2 |
| 8 | 59.6 | 0/0/0 | 102.6 | 0/0/0 | 2 | 47.4/51.2/55.0 |
| 9 | 109.2 | 0/0/0 | 97.0 | 0/0/0 | 2 | 45.9/48.4/50.9 |
| 10 | 109.7 | 0/0/0 | 99.3 | 0/0/0 | 2 | 45.9/49.6/53.3 |
| 11 | 105.9 | 0/0/0 | 103.0 | 0/0/0 | 2 | 46.2/51.4/56.7 |
| 12 | 107.7 | 0/0/0 | 95.1 | 0/0/0 | 2 | 43.3/47.5/51.7 |
| 13 | 104.7 | 0/0/0 | 104.0 | 0/0/0 | 2 | 45.7/52.0/58.2 |
| 14 | 106.8 | 0/0/0 | 96.5 | 0/0/0 | 2 | 43.6/48.2/52.7 |
| 15 | 107.2 | 0/0/0 | 102.4 | 0/0/0 | 2 | 46.1/51.1/56.2 |
| 16 | 105.5 | 0/0/0 | 97.4 | 0/0/0 | 2 | 44.7/48.6/52.6 |
| 17 | 105.9 | 0/0/0 | 107.5 | 0/0/0 | 2 | 51.0/53.7/56.4 |
| 18 | 103.2 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.0/48.2/53.5 |
| 19 | 106.5 | 0/0/0 | 102.1 | 0/0/0 | 2 | 45.5/51.0/56.4 |
| 20 | 106.8 | 0/0/0 | 99.6 | 0/0/0 | 2 | 44.6/49.7/54.9 |
| 21 | 122.8 | 0/0/0 | 104.5 | 0/0/0 | 2 | 52.1/52.2/52.3 |
| 22 | 109.8 | 0/0/0 | 104.3 | 0/0/0 | 2 | 45.8/52.1/58.5 |
| 23 | 104.3 | 0/0/0 | 98.6 | 0/0/0 | 2 | 45.4/49.2/53.0 |
| 24 | 109.7 | 0/0/0 | 98.8 | 0/0/0 | 2 | 45.9/49.3/52.8 |
| 25 | 155.1 | 0/0/0 | 101.0 | 0/0/0 | 2 | 45.5/50.4/55.4 |
| 26 | 164.8 | 0/0/0 | 102.9 | 0/0/0 | 2 | 43.9/51.4/58.8 |
| 27 | 120.4 | 0/0/0 | 101.4 | 0/0/0 | 2 | 48.2/50.7/53.1 |
| 28 | 108.6 | 0/0/0 | 101.7 | 0/0/0 | 2 | 46.9/50.8/54.7 |
| 29 | 106.0 | 0/0/0 | 98.5 | 0/0/0 | 2 | 45.5/49.2/52.9 |
| 30 | 102.9 | 0/0/0 | 94.9 | 0/0/0 | 2 | 43.6/47.4/51.2 |
| 31 | 111.3 | 0/0/0 | 99.9 | 0/0/0 | 2 | 45.6/49.9/54.2 |
| 32 | 103.3 | 0/0/0 | 96.4 | 0/0/0 | 2 | 43.6/48.1/52.6 |
| 33 | 105.8 | 0/0/0 | 104.6 | 0/0/0 | 2 | 46.4/52.2/58.0 |
| 34 | 109.3 | 0/0/0 | 95.8 | 0/0/0 | 2 | 43.2/47.8/52.4 |
| 35 | 110.2 | 0/0/0 | 102.1 | 0/0/0 | 2 | 46.3/51.0/55.7 |
| 36 | 108.0 | 0/0/0 | 101.7 | 0/0/0 | 2 | 47.5/50.8/54.0 |
| 37 | 107.6 | 0/0/0 | 96.7 | 0/0/0 | 2 | 45.0/48.3/51.5 |
| 38 | 108.9 | 0/0/0 | 96.5 | 0/0/0 | 2 | 44.7/48.2/51.6 |
| 39 | 105.2 | 0/0/0 | 98.2 | 0/0/0 | 2 | 45.6/49.0/52.5 |
| 40 | 106.7 | 0/0/0 | 99.4 | 0/0/0 | 2 | 44.4/49.6/54.9 |
| 41 | 107.0 | 0/0/0 | 101.8 | 0/0/0 | 2 | 46.9/50.8/54.8 |
| 42 | 108.6 | 0/0/0 | 106.7 | 0/0/0 | 2 | 49.2/53.3/57.4 |
| 43 | 107.0 | 0/0/0 | 102.6 | 0/0/0 | 2 | 49.9/51.2/52.6 |
| 44 | 107.7 | 0/0/0 | 100.3 | 0/0/0 | 2 | 47.4/50.1/52.7 |
| 45 | 109.5 | 0/0/0 | 102.6 | 0/0/0 | 2 | 45.8/51.2/56.7 |
| 46 | 107.6 | 0/0/0 | 104.0 | 0/0/0 | 2 | 48.4/52.0/55.5 |
| 47 | 115.6 | 0/0/0 | 99.0 | 0/0/0 | 2 | 45.9/49.4/52.9 |
| 48 | 106.0 | 0/0/0 | 97.8 | 0/0/0 | 2 | 44.9/48.8/52.8 |
| 49 | 104.5 | 0/0/0 | 98.4 | 0/0/0 | 2 | 46.1/49.1/52.2 |
| 50 | 112.5 | 0/0/0 | 94.8 | 1/1/1 | 2 | 43.0/47.3/51.7 |
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_closes_its_one_connection_with_exactly_one_forward_close [< 1 ms]
Standard Output Messages:
Native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.No_failed_libplctag_tag_is_left_with_a_live_native_callback [< 1 ms]
Standard Output Messages:
libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.A_live_value_is_compared_structurally_and_a_stable_one_by_value [3 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [36 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
Standard Output Messages:
## Browse — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
libplctag: 3162 tag(s); native: 3162 tag(s)
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
Standard Output Messages:
## Templates — 20 id(s); truncated by AB_DIFF_MAX_TEMPLATES: libplctag 74, native 74
| Template | libplctag | native | Equal? |
|---|---|---|---|
| 283 | Recipe (5 members, 1680 B) | Recipe (5 members, 1680 B) | yes |
| 295 | UDT_PartStatus (5 members, 4 B) | UDT_PartStatus (5 members, 4 B) | yes |
| 344 | Recipe_Clamp (7 members, 420 B) | Recipe_Clamp (7 members, 420 B) | yes |
| 491 | ForceSegmentEnableCheck (3 members, 4 B) | ForceSegmentEnableCheck (3 members, 4 B) | yes |
| 527 | Recipe_PowerUnit (6 members, 24 B) | Recipe_PowerUnit (6 members, 24 B) | yes |
| 622 | UDT_TemperatureProfile_Segment (11 members, 40 B) | UDT_TemperatureProfile_Segment (11 members, 40 B) | yes |
| 676 | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | yes |
| 700 | AB:5000_AI4CJ:I:0 (12 members, 68 B) | AB:5000_AI4CJ:I:0 (12 members, 68 B) | yes |
| 706 | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | yes |
| 715 | TwoHand_Control (8 members, 16 B) | TwoHand_Control (8 members, 16 B) | yes |
| 752 | STR0016 (2 members, 20 B) | STR0016 (2 members, 20 B) | yes |
| 842 | STR0032 (2 members, 36 B) | STR0032 (2 members, 36 B) | yes |
| 862 | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | yes |
| 1129 | raM_Tec_LimitWithModulo (38 members, 120 B) | raM_Tec_LimitWithModulo (38 members, 120 B) | yes |
| 1144 | PartStatus (8 members, 12 B) | PartStatus (8 members, 12 B) | yes |
| 1166 | AB:5000_DI16:C:0 (18 members, 72 B) | AB:5000_DI16:C:0 (18 members, 72 B) | yes |
| 1192 | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | yes |
| 1206 | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | yes |
| 1289 | TimeString (12 members, 376 B) | TimeString (12 members, 376 B) | yes |
| 1313 | raM_Dvc_5000AO4 (73 members, 164 B) | raM_Dvc_5000AO4 (73 members, 164 B) | yes |
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
Standard Output Messages:
Native Forward Opens over the run: 1.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [3 ms]
Standard Output Messages:
## Reads — 200 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
Selection by origin: array 20, udt member 20, scalar 160
| Tag | Type | libplctag status | native status | libplctag probe | Equal? |
|---|---|---|---|---|---|
| `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | stable | yes |
| `Local:1:I.InfoBits` | SInt | Ok | Ok | stable | yes |
| `__CONTAINER` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | stable | yes |
| `Local:1:I.RunMode` | Bool | Ok | Ok | stable | yes |
| `WW_M4ActualTemp` | Real | Ok | Ok | **live** | live |
| `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | stable | yes |
| `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
| `WW_P1_Status_Msg` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **live** | live |
| `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
| `DI_CycleStart_PB` | Bool | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | stable | yes |
| `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
| `test` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | stable | yes |
| `Local:1:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
| `UN01_EventsPTR` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
| `Local:2:I.InfoBits` | SInt | Ok | Ok | stable | yes |
| `Sts_Vacuum_Rerun` | Bool | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | stable | yes |
| `Local:2:I.RunMode` | Bool | Ok | Ok | stable | yes |
| `Ons_VacuumRerun` | Bool | Ok | Ok | stable | yes |
| `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
| `Local:2:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
| `test2` | DInt | Ok | Ok | stable | yes |
| `Program:EM02_Clamp.__fout79B2713C88B35A28` | Real[3] | Ok | Ok | stable | yes |
| `Local:2:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
| `_Val_PressurePVx` | Real | Ok | Ok | stable | yes |
| `Program:EM02_Clamp.__fout2F67CD35C2302EE0` | Real[4] | Ok | Ok | **live** | live |
| `Local:2:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
| `C_LimitType_Outside` | DInt | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB` | Real[4] | Ok | Ok | **live** | live |
| `Local:2:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
| `Par_TargetTemperature_Tolerance` | Real | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout92848E133B44AF24` | Real[8] | Ok | Ok | **live** | live |
| `Local:3:I.InfoBits` | SInt | Ok | Ok | stable | yes |
| `Cfg_RapidHeatup_Tolerancex` | DInt | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout6DC7745628658A9D` | Real[2] | Ok | Ok | **live** | live |
| `Local:3:I.RunMode` | Bool | Ok | Ok | stable | yes |
| `Cfg_RapidHeatup_Tolerance` | Real | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout7C6F52924950A71D` | Real[2] | Ok | Ok | stable | yes |
| `Local:3:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
| `test_stop` | Bool | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__foutCBB7A04F2668DCD0` | Real[2] | Ok | Ok | stable | yes |
| `Local:3:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
| `Sts_ScreenLock` | Bool | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._DisableStates` | DInt[32] | Ok | Ok | stable | yes |
| `Local:3:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
| `xSts_ClearResult` | Bool | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._ModeNames` | String[32] | Ok | Ok | stable | yes |
| `Local:3:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
| `WS2_CLOSE_Delay_Setpoint` | Int | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._ModeTransitions` | DInt[32] | Ok | Ok | stable | yes |
| `Local:5:C.CfgSize` | DInt | NotFound | NotFound | NotFound | yes |
| `WS2_Force_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._SEQ_UP` | DInt[32] | Ok | Ok | stable | yes |
| `Local:5:C.CfgIDNum` | DInt | NotFound | NotFound | NotFound | yes |
| `WS2_Force_Red_Setpoint` | Real | Ok | Ok | stable | yes |
| `C_LimitType_Inside` | DInt | Ok | Ok | stable | yes |
| `xxCfg_WaterValve_MinTime` | Real | Ok | Ok | stable | yes |
| `WS2_Force_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_Force_Yellow` | Real | Ok | Ok | stable | yes |
| `WS2_F_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WW_Green` | Bool | Ok | Ok | stable | yes |
| `WW_Down` | Bool | Ok | Ok | stable | yes |
| `WS2_F_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_HighMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_LowMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_Mold_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_Mold_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_POSITION_SP` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG1` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG10` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG2` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG3` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG4` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG5` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG6` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG7` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG8` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG9` | Real | Ok | Ok | stable | yes |
| `WS2_SPEED_SP` | Real | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
Unexpected divergences: 0; expected divergences: 0; live tags (value moved between the two libplctag reads; compared by status, length, type code, shape and every element that held still): 6 of 200.
- live: WW_M4ActualTemp (lib 4 byte(s) 0CCDC243 = 389.60193; lib probe 4 byte(s) 7084C243 = 389.03467; native 4 byte(s) 4C47C243 = 388.557)
- live: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5 (lib 20 byte(s) 0050C348218986402189A640DF76D9C021898640 = [400000,4.2042394,5.2042394,-6.7957606,4.2042394]; lib probe 20 byte(s) 0050C348E74B8640E74BA64019B4D9C0E74B8640 = [400000,4.1967654,5.1967654,-6.8032346,4.1967654]; native 20 byte(s) 0050C348BB328640BB32A64045CDD9C0BB328640 = [400000,4.1936927,5.1936927,-6.8063073,4.1936927])
- live: Program:EM02_Clamp.__fout2F67CD35C2302EE0 (lib 16 byte(s) BFB2BF379A9919BF00D68349CDCCCCBE = [2.285221E-05,-0.6,1080000,-0.4]; lib probe 16 byte(s) 5119313A9A9919BF00D68349CDCCCCBE = [0.00067557866,-0.6,1080000,-0.4]; native 16 byte(s) 1562C2B99A9919BF00D68349CDCCCCBE = [-0.0003707564,-0.6,1080000,-0.4])
- live: Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB (lib 16 byte(s) 00E14CC0CDDA75BF000000C08F2D2BC0 = [-3.201233,-0.9603699,-2,-2.6746557]; lib probe 16 byte(s) 009464C09A2589BF000000C0A84932C0 = [-3.5715332,-1.07146,-2,-2.7857456]; native 16 byte(s) 00A03AC034F35FBF000000C0A8B325C0 = [-2.9160156,-0.87480474,-2,-2.5890903])
- live: Program:ES03_EM01_Zone1.__fout92848E133B44AF24 (lib 32 byte(s) 40EDA94160F0D043000090C10022A14980256C41C656D143006A4F400000A8C1 = [21.240845,417.87793,-18,1320000,14.759155,418.67792,3.2408447,-21]; lib probe 32 byte(s) 6017AD412089D043000090C10022A14940D1654186EFD04300BB68400000A8C1 = [21.636414,417.0713,-18,1320000,14.363586,417.87128,3.6364136,-21]; native 32 byte(s) C047A841BE97CF43000090C10022A14980706F4124FECF43003E42400000A8C1 = [21.035034,415.1855,-18,1320000,14.964966,415.98547,3.0350342,-21])
- live: Program:ES03_EM01_Zone1.__fout6DC7745628658A9D (lib 8 byte(s) 00824E40CD34A53F = [3.2266846,1.2906739]; lib probe 8 byte(s) 008A6440CDD4B63F = [3.5709229,1.4283692]; native 8 byte(s) 000D3940670A943F = [2.8914185,1.1565675])
[xUnit.net 00:03:12.61] Finished: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
Standard Output Messages:
TCP connections to the target: baseline 0, before native 0, libplctag drain wait 1 ms.
Test Run Successful.
Total tests: 17
Passed: 17
Total time: 3.2143 Minutes
@@ -0,0 +1,369 @@
Test run for /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.1 (arm64)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.0.2+dd36e86129 (64-bit .NET 10.0.5)
[xUnit.net 00:00:00.35] Discovering: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
[xUnit.net 00:00:00.49] Discovered: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
[xUnit.net 00:00:00.60] Starting: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [9 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [7 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [2 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
Standard Output Messages:
libplctag connects before the abort: 1
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [13 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [2 m 56 s]
Standard Output Messages:
## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
Tags per pass: 95 (100 distinct); passes per transport: 50; gap between passes: 200 ms.
Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
Native requests spent on setup (one read per tag): 95; native Forward Opens: 1; native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
|---|---|---|---|---|---|---|---|
| libplctag | 50 | 101.8 | 197.3 | 54.6 | 219.2 | n/a | 0 |
| native | 50 | 99.4 | 105.1 | 76.0 | 139.3 | 2 | 0 |
Criterion: native p95 105.1 ms ≤ libplctag p95 197.3 ms × 1.10 = 217.1 ms → PASS
| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |
|---|---|---|---|---|---|---|
| 1 | 99.9 | 0/0/0 | 99.6 | 0/0/0 | 2 | 45.0/49.4/53.8 |
| 2 | 101.7 | 0/0/0 | 101.3 | 0/0/0 | 2 | 48.1/50.5/53.0 |
| 3 | 100.0 | 0/0/0 | 99.6 | 0/0/0 | 2 | 45.7/49.7/53.7 |
| 4 | 102.4 | 0/0/0 | 98.9 | 1/0/0 | 2 | 48.2/49.4/50.5 |
| 5 | 105.8 | 0/0/0 | 97.1 | 0/0/0 | 2 | 44.8/48.5/52.2 |
| 6 | 103.5 | 0/0/0 | 99.8 | 0/0/0 | 2 | 45.3/49.8/54.3 |
| 7 | 100.5 | 0/0/0 | 105.1 | 0/0/0 | 2 | 51.2/52.5/53.8 |
| 8 | 105.7 | 0/0/0 | 94.4 | 0/0/0 | 2 | 43.3/47.1/50.9 |
| 9 | 99.8 | 0/0/0 | 99.1 | 0/0/0 | 2 | 47.0/49.5/51.9 |
| 10 | 102.8 | 0/0/0 | 98.1 | 0/0/0 | 2 | 48.4/49.0/49.5 |
| 11 | 54.6 | 0/0/0 | 98.7 | 0/0/0 | 2 | 44.0/49.3/54.5 |
| 12 | 99.8 | 0/0/0 | 105.2 | 0/0/0 | 2 | 44.6/52.5/60.5 |
| 13 | 101.5 | 0/0/0 | 94.9 | 0/0/0 | 2 | 43.7/47.4/51.0 |
| 14 | 109.8 | 0/0/0 | 98.9 | 0/0/0 | 2 | 46.9/49.4/51.8 |
| 15 | 106.7 | 0/0/0 | 96.3 | 0/0/0 | 2 | 43.2/48.0/52.8 |
| 16 | 165.0 | 0/0/0 | 99.4 | 0/0/0 | 2 | 46.6/49.6/52.5 |
| 17 | 197.3 | 0/0/0 | 101.3 | 0/0/0 | 2 | 45.7/50.6/55.4 |
| 18 | 71.0 | 0/0/0 | 98.8 | 0/0/0 | 2 | 45.3/49.3/53.4 |
| 19 | 219.2 | 0/0/0 | 101.0 | 0/0/0 | 2 | 48.0/50.4/52.8 |
| 20 | 168.8 | 0/0/0 | 100.5 | 0/0/0 | 2 | 46.2/50.1/54.1 |
| 21 | 95.3 | 0/0/0 | 98.3 | 0/0/0 | 2 | 45.7/49.0/52.4 |
| 22 | 133.9 | 0/0/0 | 98.9 | 0/0/0 | 2 | 46.4/49.4/52.3 |
| 23 | 163.0 | 0/0/0 | 97.2 | 0/0/0 | 2 | 44.9/48.5/52.1 |
| 24 | 184.5 | 0/0/0 | 101.4 | 0/0/0 | 2 | 49.1/50.6/52.2 |
| 25 | 130.9 | 0/0/0 | 97.2 | 0/0/0 | 2 | 43.7/48.5/53.3 |
| 26 | 76.8 | 0/0/0 | 103.2 | 0/0/0 | 2 | 47.3/51.5/55.8 |
| 27 | 99.6 | 0/0/0 | 76.0 | 0/0/0 | 1 | 75.8/75.8/75.8 |
| 28 | 113.8 | 0/0/0 | 100.3 | 0/0/0 | 2 | 46.4/50.1/53.7 |
| 29 | 100.9 | 0/0/0 | 96.0 | 0/0/0 | 2 | 44.2/47.9/51.6 |
| 30 | 106.0 | 0/0/0 | 103.0 | 0/0/0 | 2 | 49.9/51.4/53.0 |
| 31 | 107.3 | 0/0/0 | 103.7 | 0/0/0 | 2 | 47.4/51.8/56.1 |
| 32 | 100.9 | 0/0/0 | 102.4 | 0/0/0 | 2 | 47.4/51.1/54.8 |
| 33 | 99.9 | 0/0/0 | 98.6 | 0/0/0 | 2 | 46.0/49.2/52.4 |
| 34 | 101.4 | 0/0/0 | 98.7 | 0/0/0 | 2 | 46.7/49.3/51.8 |
| 35 | 97.8 | 0/0/0 | 98.9 | 0/0/0 | 2 | 46.3/49.4/52.5 |
| 36 | 98.7 | 0/0/0 | 98.5 | 0/0/0 | 2 | 45.2/49.2/53.2 |
| 37 | 101.2 | 0/0/0 | 100.1 | 0/0/0 | 2 | 45.2/49.9/54.7 |
| 38 | 102.6 | 0/0/0 | 100.0 | 0/0/0 | 2 | 44.0/49.9/55.8 |
| 39 | 103.5 | 0/0/0 | 97.7 | 0/0/0 | 2 | 45.0/48.8/52.5 |
| 40 | 104.5 | 0/0/0 | 99.6 | 0/0/0 | 2 | 46.0/49.7/53.5 |
| 41 | 103.0 | 0/0/0 | 99.4 | 0/0/0 | 2 | 45.4/49.6/53.9 |
| 42 | 101.8 | 0/0/0 | 98.7 | 0/0/0 | 2 | 46.0/49.2/52.4 |
| 43 | 101.8 | 0/0/0 | 139.3 | 0/0/0 | 2 | 45.6/69.6/93.6 |
| 44 | 109.4 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.2/48.2/53.3 |
| 45 | 101.6 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.2/48.2/53.2 |
| 46 | 101.6 | 0/0/0 | 102.8 | 0/0/0 | 2 | 47.9/51.3/54.8 |
| 47 | 204.5 | 0/0/0 | 101.0 | 0/0/0 | 2 | 47.3/50.4/53.5 |
| 48 | 108.0 | 0/0/0 | 100.0 | 0/0/0 | 2 | 47.4/49.9/52.4 |
| 49 | 99.7 | 0/0/0 | 101.3 | 0/0/0 | 2 | 48.3/50.6/52.8 |
| 50 | 99.7 | 0/0/0 | 99.4 | 1/1/1 | 2 | 46.0/49.6/53.2 |
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_closes_its_one_connection_with_exactly_one_forward_close [< 1 ms]
Standard Output Messages:
Native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.No_failed_libplctag_tag_is_left_with_a_live_native_callback [< 1 ms]
Standard Output Messages:
libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.A_live_value_is_compared_structurally_and_a_stable_one_by_value [3 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [36 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
Standard Output Messages:
## Browse — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
libplctag: 3162 tag(s); native: 3162 tag(s)
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
Standard Output Messages:
## Templates — 20 id(s); truncated by AB_DIFF_MAX_TEMPLATES: libplctag 74, native 74
| Template | libplctag | native | Equal? |
|---|---|---|---|
| 283 | Recipe (5 members, 1680 B) | Recipe (5 members, 1680 B) | yes |
| 295 | UDT_PartStatus (5 members, 4 B) | UDT_PartStatus (5 members, 4 B) | yes |
| 344 | Recipe_Clamp (7 members, 420 B) | Recipe_Clamp (7 members, 420 B) | yes |
| 491 | ForceSegmentEnableCheck (3 members, 4 B) | ForceSegmentEnableCheck (3 members, 4 B) | yes |
| 527 | Recipe_PowerUnit (6 members, 24 B) | Recipe_PowerUnit (6 members, 24 B) | yes |
| 622 | UDT_TemperatureProfile_Segment (11 members, 40 B) | UDT_TemperatureProfile_Segment (11 members, 40 B) | yes |
| 676 | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | yes |
| 700 | AB:5000_AI4CJ:I:0 (12 members, 68 B) | AB:5000_AI4CJ:I:0 (12 members, 68 B) | yes |
| 706 | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | yes |
| 715 | TwoHand_Control (8 members, 16 B) | TwoHand_Control (8 members, 16 B) | yes |
| 752 | STR0016 (2 members, 20 B) | STR0016 (2 members, 20 B) | yes |
| 842 | STR0032 (2 members, 36 B) | STR0032 (2 members, 36 B) | yes |
| 862 | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | yes |
| 1129 | raM_Tec_LimitWithModulo (38 members, 120 B) | raM_Tec_LimitWithModulo (38 members, 120 B) | yes |
| 1144 | PartStatus (8 members, 12 B) | PartStatus (8 members, 12 B) | yes |
| 1166 | AB:5000_DI16:C:0 (18 members, 72 B) | AB:5000_DI16:C:0 (18 members, 72 B) | yes |
| 1192 | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | yes |
| 1206 | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | yes |
| 1289 | TimeString (12 members, 376 B) | TimeString (12 members, 376 B) | yes |
| 1313 | raM_Dvc_5000AO4 (73 members, 164 B) | raM_Dvc_5000AO4 (73 members, 164 B) | yes |
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
Standard Output Messages:
Native Forward Opens over the run: 1.
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [3 ms]
Standard Output Messages:
## Reads — 200 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
Selection by origin: array 20, udt member 20, scalar 160
| Tag | Type | libplctag status | native status | libplctag probe | Equal? |
|---|---|---|---|---|---|
| `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | stable | yes |
| `Local:1:I.InfoBits` | SInt | Ok | Ok | stable | yes |
| `__CONTAINER` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | stable | yes |
| `Local:1:I.RunMode` | Bool | Ok | Ok | stable | yes |
| `WW_M4ActualTemp` | Real | Ok | Ok | **live** | live |
| `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | stable | yes |
| `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
| `WW_P1_Status_Msg` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **live** | live |
| `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
| `DI_CycleStart_PB` | Bool | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | stable | yes |
| `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
| `test` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | stable | yes |
| `Local:1:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
| `UN01_EventsPTR` | DInt | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
| `Local:2:I.InfoBits` | SInt | Ok | Ok | stable | yes |
| `Sts_Vacuum_Rerun` | Bool | Ok | Ok | stable | yes |
| `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | stable | yes |
| `Local:2:I.RunMode` | Bool | Ok | Ok | stable | yes |
| `Ons_VacuumRerun` | Bool | Ok | Ok | stable | yes |
| `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
| `Local:2:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
| `test2` | DInt | Ok | Ok | stable | yes |
| `Program:EM02_Clamp.__fout79B2713C88B35A28` | Real[3] | Ok | Ok | stable | yes |
| `Local:2:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
| `_Val_PressurePVx` | Real | Ok | Ok | stable | yes |
| `Program:EM02_Clamp.__fout2F67CD35C2302EE0` | Real[4] | Ok | Ok | **live** | live |
| `Local:2:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
| `C_LimitType_Outside` | DInt | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB` | Real[4] | Ok | Ok | **live** | live |
| `Local:2:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
| `Par_TargetTemperature_Tolerance` | Real | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout92848E133B44AF24` | Real[8] | Ok | Ok | **live** | live |
| `Local:3:I.InfoBits` | SInt | Ok | Ok | stable | yes |
| `Cfg_RapidHeatup_Tolerancex` | DInt | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout6DC7745628658A9D` | Real[2] | Ok | Ok | **live** | live |
| `Local:3:I.RunMode` | Bool | Ok | Ok | stable | yes |
| `Cfg_RapidHeatup_Tolerance` | Real | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__fout7C6F52924950A71D` | Real[2] | Ok | Ok | stable | yes |
| `Local:3:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
| `test_stop` | Bool | Ok | Ok | stable | yes |
| `Program:ES03_EM01_Zone1.__foutCBB7A04F2668DCD0` | Real[2] | Ok | Ok | stable | yes |
| `Local:3:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
| `Sts_ScreenLock` | Bool | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._DisableStates` | DInt[32] | Ok | Ok | stable | yes |
| `Local:3:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
| `xSts_ClearResult` | Bool | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._ModeNames` | String[32] | Ok | Ok | stable | yes |
| `Local:3:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
| `WS2_CLOSE_Delay_Setpoint` | Int | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._ModeTransitions` | DInt[32] | Ok | Ok | stable | yes |
| `Local:5:C.CfgSize` | DInt | NotFound | NotFound | NotFound | yes |
| `WS2_Force_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `Program:ES03_TemperatureControl._SEQ_UP` | DInt[32] | Ok | Ok | stable | yes |
| `Local:5:C.CfgIDNum` | DInt | NotFound | NotFound | NotFound | yes |
| `WS2_Force_Red_Setpoint` | Real | Ok | Ok | stable | yes |
| `C_LimitType_Inside` | DInt | Ok | Ok | stable | yes |
| `xxCfg_WaterValve_MinTime` | Real | Ok | Ok | stable | yes |
| `WS2_Force_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_Force_Yellow` | Real | Ok | Ok | stable | yes |
| `WS2_F_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WW_Green` | Bool | Ok | Ok | stable | yes |
| `WW_Down` | Bool | Ok | Ok | stable | yes |
| `WS2_F_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_F_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_HighMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_LowMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_Mold_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_Mold_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_PH_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
| `WS2_POSITION_SP` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_MOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_RAMP_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG1` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG10` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG2` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG3` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG4` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG5` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG6` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG7` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG8` | Real | Ok | Ok | stable | yes |
| `WS2_PSI_SP_SEG9` | Real | Ok | Ok | stable | yes |
| `WS2_SPEED_SP` | Real | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
| `WS2_TEMP_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
Unexpected divergences: 0; expected divergences: 0; live tags (value moved between the two libplctag reads; compared by status, length, type code, shape and every element that held still): 6 of 200.
- live: WW_M4ActualTemp (lib 4 byte(s) 20526943 = 233.3208; lib probe 4 byte(s) 908D6843 = 232.55298; native 4 byte(s) 501F6743 = 231.12231)
- live: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5 (lib 20 byte(s) 0050C3487C144C403E0A8640C2F5F9C07C144C40 = [400000,3.1887503,4.1887503,-7.8112497,3.1887503]; lib probe 20 byte(s) 0050C3481C244A400E128540F2EDFAC01C244A40 = [400000,3.158454,4.158454,-7.841546,3.158454]; native 20 byte(s) 0050C34831AA454018D58240E82AFDC031AA4540 = [400000,3.0885127,4.0885124,-7.9114876,3.0885127])
- live: Program:EM02_Clamp.__fout2F67CD35C2302EE0 (lib 16 byte(s) 96D41EB99A9919BF00D68349CDCCCCBE = [-0.00015147249,-0.6,1080000,-0.4]; lib probe 16 byte(s) A830FE389A9919BF00D68349CDCCCCBE = [0.00012120727,-0.6,1080000,-0.4]; native 16 byte(s) D34D8FB99A9919BF00D68349CDCCCCBE = [-0.0002733307,-0.6,1080000,-0.4])
- live: Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB (lib 16 byte(s) 11E416C300000000000000C000000000 = [-150.89088,0,-2,0]; lib probe 16 byte(s) F1E910C300000000000000C000000000 = [-144.91383,0,-2,0]; native 16 byte(s) 194008C300000000000000C000000000 = [-136.25038,0,-2,0])
- live: Program:ES03_EM01_Zone1.__fout92848E133B44AF24 (lib 32 byte(s) 91D52843C4085243000090C10022A14991D504C391D5524391D516430000A8C1 = [168.83424,210.03424,-18,1320000,-132.83424,210.83424,150.83424,-21]; lib probe 32 byte(s) CDD7224398074C43000090C10022A1499AAFFDC265D44C43CDD710430000A8C1 = [162.84297,204.02966,-18,1320000,-126.84297,204.82967,144.84297,-21]; native 32 byte(s) 2D311A4388634343000090C10022A1495A62ECC2553044432D3108430000A8C1 = [154.1921,195.3888,-18,1320000,-118.19209,196.1888,136.1921,-21])
- live: Program:ES03_EM01_Zone1.__fout6DC7745628658A9D (lib 8 byte(s) 4DC71643E23E7142 = [150.77852,60.31141]; lib probe 8 byte(s) F5BA104388916742 = [144.7303,57.89212]; native 8 byte(s) 650B0843D5AB5942 = [136.04451,54.417805])
[xUnit.net 00:03:06.03] Finished: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
Standard Output Messages:
TCP connections to the target: baseline 0, before native 0, libplctag drain wait 1 ms.
Test Run Successful.
Total tests: 17
Passed: 17
Total time: 3.1046 Minutes
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Golden-frame capture recipe for the native AB transport (Task 4 of
# docs/plans/2026-10-07-native-ab-transport.md).
#
# Captures the EtherNet/IP conversation between libplctag (the libplctag 1.5.2
# NuGet wrapper, native core 2.6.0) and the ab_server simulator fixtures, and
# converts each pcap into the `.hex` line format consumed by
# tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures:
#
# > <client->server EIP frame, uppercase hex, no spaces>
# < <server->client EIP frame>
# # comment
#
# Packets are captured INSIDE the simulator container's network namespace
# (netshoot sidecar), where ab_server always listens on 44818 — so the same
# filter works for the AbLegacy containers whose host ports are 44819-44821.
# tshark also runs in the netshoot image on the docker host, so nothing needs
# installing on the workstation beyond python3.
#
# Usage:
# CAPDIR=<fresh empty dir> scripts/ab-capture/capture.sh run <container> <name> -- <client command...>
# scripts/ab-capture/capture.sh tohex <pcap> <out.hex>
# scripts/ab-capture/capture.sh client <client args...>
#
# `client` builds (once) and runs scripts/ab-capture/client — the console program
# over libplctag 1.5.2 that took the committed fixtures: one tag, one Initialize +
# Read/Write, then Dispose + plc_tag_shutdown so the Forward Close reaches the wire.
# No UnregisterSession is ever sent; the session ends with a TCP RST. Example:
#
# CAPDIR=$PWD/caps scripts/ab-capture/capture.sh run otopcua-ab-server-controllogix logix-read-dint -- \
# scripts/ab-capture/capture.sh client read 10.100.0.35:44818 1,0 ControlLogix TestDINT dint
#
# See Captures/README.md for every invocation used.
#
# Fixtures (one AbCip profile at a time — they all bind host :44818):
# ssh dohertj2@10.100.0.35 'cd /opt/otopcua-abcip && docker compose --profile controllogix up -d'
# ssh dohertj2@10.100.0.35 'cd ~/otopcua-ablegacy && docker compose --profile slc500 up -d'
set -euo pipefail
DOCKER_HOST_SSH="${DOCKER_HOST_SSH:-dohertj2@10.100.0.35}"
NETSHOOT="${NETSHOOT:-nicolaka/netshoot:v0.13}"
REMOTE_DIR="${REMOTE_DIR:-/tmp/abcap}"
cmd="${1:-}"; shift || true
case "$cmd" in
client)
here="$(cd "$(dirname "$0")" && pwd)"
dll="$here/client/bin/Release/net10.0/ab-capture-client.dll"
[[ -f "$dll" ]] || dotnet build "$here/client/ab-capture-client.csproj" -c Release -v q >&2
exec dotnet "$dll" "$@"
;;
run)
container="$1"; name="$2"; shift 2
[[ "${1:-}" == "--" ]] && shift
: "${CAPDIR:?set CAPDIR to a fresh empty directory}"
sidecar="abcap-$name"
ssh "$DOCKER_HOST_SSH" "mkdir -p $REMOTE_DIR && rm -f $REMOTE_DIR/$name.pcap && \
docker run -d --rm --name $sidecar --net=container:$container -v $REMOTE_DIR:/cap $NETSHOOT \
tcpdump -i eth0 -U -w /cap/$name.pcap tcp port 44818 >/dev/null"
sleep 2 # let tcpdump open the interface before the client connects
"$@" || echo "client exited non-zero: $?" >&2
sleep 1
ssh "$DOCKER_HOST_SSH" "docker stop $sidecar >/dev/null"
scp -q "$DOCKER_HOST_SSH:$REMOTE_DIR/$name.pcap" "$CAPDIR/$name.pcap"
echo "$CAPDIR/$name.pcap"
;;
tohex)
pcap="$1"; out="$2"
base="$(basename "$pcap")"
ssh "$DOCKER_HOST_SSH" "mkdir -p $REMOTE_DIR"
scp -q "$pcap" "$DOCKER_HOST_SSH:$REMOTE_DIR/tohex-$base"
# srcport + payload of every data-bearing, non-retransmitted segment, in capture order.
ssh "$DOCKER_HOST_SSH" "docker run --rm -v $REMOTE_DIR:/cap $NETSHOOT \
tshark -r /cap/tohex-$base -T fields -e tcp.srcport -e tcp.payload \
-Y 'tcp.len>0 && !tcp.analysis.retransmission'" \
| python3 -I -c '
import sys
# Re-join TCP segments into EIP encapsulation frames per direction, using the
# 24-byte header length field (offset 2, UInt16 LE) to find frame boundaries.
bufs = {True: b"", False: b""}
for line in sys.stdin:
line = line.strip()
if not line:
continue
port, payload = line.split("\t")
from_client = port != "44818" # srcport 44818 => server->client
bufs[from_client] += bytes.fromhex(payload.replace(":", ""))
b = bufs[from_client]
while len(b) >= 24:
total = 24 + int.from_bytes(b[2:4], "little")
if len(b) < total:
break
print(("> " if from_client else "< ") + b[:total].hex().upper())
b = b[total:]
bufs[from_client] = b
for k, rest in bufs.items():
if rest:
sys.exit(f"trailing partial frame ({len(rest)} bytes, client={k})")
' > "$out"
echo "$out: $(grep -c '^[<>]' "$out") frames"
;;
*)
sed -n '2,37p' "$0"; exit 2 ;;
esac
+169
View File
@@ -0,0 +1,169 @@
// Capture client for scripts/ab-capture/capture.sh.
//
// One libplctag tag, one operation, then Dispose + plc_tag_shutdown so the Forward Close
// reaches the wire. The tag is built with exactly the properties the drivers set
// (LibplctagTagRuntime / LibplctagLegacyTagRuntime / LibplctagTagEnumerator): Gateway, Path,
// PlcType, Protocol=ab_eip, Name, Timeout, and ElementCount when > 1. Nothing else.
//
// usage:
// ab-capture-client version
// ab-capture-client read <host:port> <path|-> <plc> <name> <type> [count]
// ab-capture-client write <host:port> <path|-> <plc> <name> <type> <count> <value>
//
// <plc> a libplctag.PlcType name: ControlLogix, Micro800, Slc500, MicroLogix, Plc5, LogixPccc
// <type> dint | int | real | string | bool | raw (read only: hex dump of the buffer)
// dintseq (write only): element i (0-based) = ((i+1) << 24) | (i+1), for <count> elements;
// <value> is ignored but required.
// env UNCONNECTED=1 sets UseConnectedMessaging=false (ignored by libplctag 2.6.0 for Micro800).
using libplctag;
using libplctag.NativeImport;
if (args.Length == 0)
{
Console.Error.WriteLine("usage: see the header of Program.cs");
return 2;
}
if (args[0] == "seed-bool")
{
// ab_server rejects libplctag's BOOL write with CIP 0x03: libplctag sends two data bytes for a
// one-byte BOOL and ab_server's "too much data" check refuses it. This seeds a BOOL for a
// later libplctag READ capture with one raw UCMM Write Tag carrying exactly one data byte.
// usage: seed-bool <host> <port> <tag> <0|1> (route is port 1 / slot 0)
return await SeedBoolAsync(args[1], int.Parse(args[2]), args[3], args[4] == "1" ? (byte)1 : (byte)0);
}
if (args[0] == "version")
{
Console.WriteLine($"{plctag.plc_tag_get_int_attribute(0, "version_major", -1)}." +
$"{plctag.plc_tag_get_int_attribute(0, "version_minor", -1)}." +
$"{plctag.plc_tag_get_int_attribute(0, "version_patch", -1)}");
return 0;
}
var op = args[0];
var gateway = args[1];
var path = args[2] == "-" ? null : args[2];
var plc = Enum.Parse<PlcType>(args[3], ignoreCase: true);
var name = args[4];
var type = args[5];
var count = args.Length > 6 ? int.Parse(args[6]) : 1;
var tag = new Tag
{
Gateway = gateway,
Path = path,
PlcType = plc,
Protocol = Protocol.ab_eip,
Name = name,
Timeout = TimeSpan.FromSeconds(5),
};
if (count > 1) tag.ElementCount = count;
if (Environment.GetEnvironmentVariable("UNCONNECTED") == "1") tag.UseConnectedMessaging = false;
var exit = 0;
try
{
await tag.InitializeAsync();
if (op == "write")
{
var value = args[7];
switch (type)
{
case "dint": tag.SetInt32(0, int.Parse(value)); break;
case "int": tag.SetInt16(0, short.Parse(value)); break;
case "real": tag.SetFloat32(0, float.Parse(value)); break;
case "string": tag.SetString(0, value); break;
// As LibplctagTagRuntime does: a whole-byte write (SetBit on a Logix BOOL is ErrorBadParam).
case "bool": tag.SetInt8(0, value is "1" or "true" ? (sbyte)1 : (sbyte)0); break;
case "dintseq":
for (var i = 0; i < count; i++) tag.SetInt32(i * 4, ((i + 1) << 24) | (i + 1));
break;
default: throw new ArgumentException($"unknown write type {type}");
}
await tag.WriteAsync();
Console.WriteLine("write ok");
}
else
{
await tag.ReadAsync();
Console.WriteLine($"read ok size={tag.GetSize()}");
Console.WriteLine(type switch
{
"dint" => tag.GetInt32(0).ToString(),
"int" => tag.GetInt16(0).ToString(),
"real" => tag.GetFloat32(0).ToString(),
"string" => tag.GetString(0),
"bool" => tag.GetBit(0).ToString(),
_ => Convert.ToHexString(tag.GetBuffer()),
});
}
}
catch (Exception ex)
{
Console.WriteLine($"EXC {ex.GetType().Name}: {ex.Message}");
exit = 1;
}
finally
{
tag.Dispose();
}
plctag.plc_tag_shutdown();
await Task.Delay(500); // let the Forward Close flush before the process exits
return exit;
static async Task<int> SeedBoolAsync(string host, int port, string tagName, byte value)
{
using var tcp = new System.Net.Sockets.TcpClient();
await tcp.ConnectAsync(host, port);
var s = tcp.GetStream();
// RegisterSession
var reg = new byte[28];
reg[0] = 0x65; reg[2] = 4; reg[24] = 1;
await s.WriteAsync(reg);
var regReply = await ReadFrameAsync(s);
var session = BitConverter.ToUInt32(regReply, 4);
// Write Tag (0x4D): ANSI symbol segment, type BOOL 0xC1, count 1, ONE data byte.
var name = System.Text.Encoding.ASCII.GetBytes(tagName);
var sym = new List<byte> { 0x91, (byte)name.Length };
sym.AddRange(name);
if (sym.Count % 2 == 1) sym.Add(0);
var write = new List<byte> { 0x4D, (byte)(sym.Count / 2) };
write.AddRange(sym);
write.AddRange([0xC1, 0x00, 0x01, 0x00, value]);
// Unconnected Send (0x52) to the Connection Manager, routed to port 1 / slot 0.
var ucmm = new List<byte> { 0x52, 0x02, 0x20, 0x06, 0x24, 0x01, 0x0A, 0x05 };
ucmm.AddRange(BitConverter.GetBytes((ushort)write.Count));
ucmm.AddRange(write);
if (write.Count % 2 == 1) ucmm.Add(0);
ucmm.AddRange([0x01, 0x00, 0x01, 0x00]);
// SendRRData: interface handle, timeout, 2 items (null address, unconnected data).
var data = new List<byte> { 0, 0, 0, 0, 0x0A, 0, 2, 0, 0, 0, 0, 0, 0xB2, 0 };
data.AddRange(BitConverter.GetBytes((ushort)ucmm.Count));
data.AddRange(ucmm);
var frame = new byte[24 + data.Count];
frame[0] = 0x6F;
BitConverter.GetBytes((ushort)data.Count).CopyTo(frame, 2);
BitConverter.GetBytes(session).CopyTo(frame, 4);
data.CopyTo(frame, 24);
await s.WriteAsync(frame);
var reply = await ReadFrameAsync(s);
var cip = reply.AsSpan(24 + 16).ToArray(); // after handle, timeout, count, null item, data item header
Console.WriteLine($"seed-bool reply CIP: {Convert.ToHexString(cip)}");
return cip.Length >= 3 && cip[0] == 0xCD && cip[2] == 0 ? 0 : 1;
}
static async Task<byte[]> ReadFrameAsync(System.Net.Sockets.NetworkStream s)
{
var header = new byte[24];
await s.ReadExactlyAsync(header);
var body = new byte[BitConverter.ToUInt16(header, 2)];
await s.ReadExactlyAsync(body);
return [.. header, .. body];
}
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Capture client for scripts/ab-capture/capture.sh — drives libplctag 1.5.2 against the ab_server
fixtures so the golden frames in tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures
can be re-taken. Deliberately NOT in ZB.MOM.WW.OtOpcUa.slnx. Pinned to the exact libplctag the
drivers use; CPM is switched off so the pin lives here and cannot drift with Directory.Packages.props.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="libplctag" Version="1.5.2" />
</ItemGroup>
</Project>
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli;
/// <summary>
/// The CLI's <c>--transport</c> choice. <see cref="Default"/> leaves
/// <see cref="AbCipDriverOptions.Transport"/> null so the driver resolves it through
/// <see cref="AbCipTransportResolver"/> exactly as an absent <c>transport</c> key in a deployed config does.
/// </summary>
public enum AbCipCliTransport
{
/// <summary>Use the driver's default (resolved by <see cref="AbCipTransportResolver"/>).</summary>
Default,
/// <summary>The managed native transport.</summary>
Native,
/// <summary>The libplctag fallback transport.</summary>
Libplctag,
}
/// <summary>Maps a <see cref="AbCipCliTransport"/> onto the driver option.</summary>
public static class AbCipCliTransportExtensions
{
/// <summary>Returns the configured transport: null for <see cref="AbCipCliTransport.Default"/>.</summary>
/// <param name="choice">The CLI choice.</param>
/// <returns>The value for <see cref="AbCipDriverOptions.Transport"/>.</returns>
public static AbCipTransport? ToConfigured(this AbCipCliTransport choice) => choice switch
{
AbCipCliTransport.Native => AbCipTransport.Native,
AbCipCliTransport.Libplctag => AbCipTransport.Libplctag,
_ => null,
};
}
@@ -26,6 +26,11 @@ public abstract class AbCipCommandBase : DriverCommandBase
"ControlLogix / CompactLogix / Micro800 / GuardLogix (default ControlLogix).")]
public AbCipPlcFamily Family { get; init; } = AbCipPlcFamily.ControlLogix;
/// <summary>Gets the transport stack used to reach the PLC.</summary>
[CommandOption("transport", Description =
"Transport stack: default (= native), native (managed EtherNet/IP + CIP) or libplctag (fallback).")]
public AbCipCliTransport Transport { get; init; } = AbCipCliTransport.Default;
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
public int TimeoutMs { get; init; } = 5000;
@@ -68,6 +73,7 @@ public abstract class AbCipCommandBase : DriverCommandBase
WriteIdempotent: t.WriteIdempotent,
DeviceName: t.DeviceHostAddress))],
Timeout = Timeout,
Transport = Transport.ToConfigured(),
Probe = new AbCipProbeOptions { Enabled = false },
EnableControllerBrowse = false,
EnableAlarmProjection = false,
@@ -6,6 +6,6 @@ return await new CliApplicationBuilder()
.SetDescription(
"OtOpcUa AB CIP test-client — ad-hoc probe + Logix symbolic reads/writes + polled " +
"subscriptions against ControlLogix / CompactLogix / Micro800 / GuardLogix families " +
"via libplctag. Second of six driver CLIs; mirrors otopcua-modbus-cli's shape.")
"via libplctag or the native transport. Second of six driver CLIs; mirrors otopcua-modbus-cli's shape.")
.Build()
.RunAsync(args);
@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli;
/// <summary>
/// The CLI's <c>--transport</c> choice. <see cref="Default"/> leaves
/// <see cref="AbLegacyDriverOptions.Transport"/> null so the driver resolves it through
/// <see cref="AbLegacyTransportResolver"/> exactly as an absent <c>transport</c> key in a deployed config does.
/// </summary>
public enum AbLegacyCliTransport
{
/// <summary>Use the driver's default (resolved by <see cref="AbLegacyTransportResolver"/>).</summary>
Default,
/// <summary>The managed native transport.</summary>
Native,
/// <summary>The libplctag fallback transport.</summary>
Libplctag,
}
/// <summary>Maps a <see cref="AbLegacyCliTransport"/> onto the driver option.</summary>
public static class AbLegacyCliTransportExtensions
{
/// <summary>Returns the configured transport: null for <see cref="AbLegacyCliTransport.Default"/>.</summary>
/// <param name="choice">The CLI choice.</param>
/// <returns>The value for <see cref="AbLegacyDriverOptions.Transport"/>.</returns>
public static AbLegacyTransport? ToConfigured(this AbLegacyCliTransport choice) => choice switch
{
AbLegacyCliTransport.Native => AbLegacyTransport.Native,
AbLegacyCliTransport.Libplctag => AbLegacyTransport.Libplctag,
_ => null,
};
}
@@ -25,6 +25,13 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
"Slc500 / MicroLogix / Plc5 / LogixPccc (default Slc500).")]
public AbLegacyPlcFamily PlcType { get; init; } = AbLegacyPlcFamily.Slc500;
/// <summary>Gets the transport stack used to reach the PLC.</summary>
[CommandOption("transport", Description =
"Transport stack: default, native or libplctag. default = libplctag for every --plc-type; native is " +
"opt-in (wire-proven only for N-file reads/writes and an F8 read; PLC-5 native is N-file reads only, " +
"LogixPccc unverified).")]
public AbLegacyCliTransport Transport { get; init; } = AbLegacyCliTransport.Default;
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
public int TimeoutMs { get; init; } = 5000;
@@ -56,6 +63,7 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
RawTags = [.. tags.Select(t => new RawTagEntry(
t.Name, AbLegacyTagDefinitionFactory.ToTagConfig(t), t.WriteIdempotent, DeviceName: t.DeviceHostAddress))],
Timeout = Timeout,
Transport = Transport.ToConfigured(),
Probe = new AbLegacyProbeOptions { Enabled = false },
};
@@ -5,7 +5,7 @@ return await new CliApplicationBuilder()
.SetExecutableName("otopcua-ablegacy-cli")
.SetDescription(
"OtOpcUa AB Legacy test-client — ad-hoc probe + PCCC N/F/B/L-file reads/writes + " +
"polled subscriptions against SLC 500 / MicroLogix / PLC-5 devices via libplctag. " +
"polled subscriptions against SLC 500 / MicroLogix / PLC-5 devices via libplctag or the native transport. " +
"Addresses use PCCC convention: N7:0, F8:0, B3:0/3, L19:0, ST17:0.")
.Build()
.RunAsync(args);
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
/// <summary>Transport-neutral status shared by the libplctag and native AB transports.
/// Zero is success; every error is negative so existing <c>status != 0</c> checks stay correct.</summary>
public enum AbStatus
{
Ok = 0,
Pending = 1,
Timeout = -1,
NotFound = -2,
NotAllowed = -3,
OutOfBounds = -4,
TooLarge = -5,
TooSmall = -6,
Unsupported = -7,
BadConnection = -8,
BadReply = -9,
Partial = -10,
Abort = -11,
TypeMismatch = -12,
DeviceStateConflict = -13,
}
@@ -0,0 +1,31 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
public static class AbStatusCodes
{
public static AbStatus FromCip(byte general, ReadOnlySpan<ushort> extended) => general switch
{
0x00 or 0x0B => AbStatus.Ok,
0x04 or 0x05 or 0x16 => AbStatus.NotFound,
0x06 => AbStatus.Partial,
0x08 => AbStatus.Unsupported,
0x0A => AbStatus.OutOfBounds,
0x0C or 0x10 => AbStatus.DeviceStateConflict,
0x0E or 0x0F => AbStatus.NotAllowed,
0x13 => AbStatus.TooSmall,
0x15 => AbStatus.TooLarge,
0xFF when extended.Length > 0 && extended[0] == 0x2105 => AbStatus.OutOfBounds,
0xFF when extended.Length > 0 && extended[0] == 0x2107 => AbStatus.TypeMismatch,
_ => AbStatus.BadReply,
};
public static AbStatus FromPccc(byte sts, byte? extSts) => sts switch
{
0x00 => AbStatus.Ok,
0x10 => AbStatus.Unsupported,
0x20 => AbStatus.NotFound,
0x30 => AbStatus.NotAllowed,
0x40 or 0x50 => AbStatus.DeviceStateConflict,
0xF0 when extSts is 0x06 or 0x07 => AbStatus.NotFound,
_ => AbStatus.BadReply,
};
}
@@ -0,0 +1,9 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
/// <summary>Thrown by both AB transports. <see cref="Status"/> drives OPC UA status mapping and the
/// driver's "controller answered, don't throttle the device" classification (05/STAB-8).</summary>
public sealed class AbTransportException(AbStatus status, string message, Exception? inner = null)
: Exception(message, inner)
{
public AbStatus Status { get; } = status;
}
@@ -0,0 +1,412 @@
using System.Diagnostics;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>
/// One device's CIP channel: one EtherNet/IP session and, when <see cref="CipRouting.Connected"/>, one
/// class-3 connection. Opens lazily on first use, reconnects (through the <see cref="ForwardOpenThrottle"/>)
/// after the session faults, and keeps an idle connection alive.
/// </summary>
/// <remarks>
/// <para><b>Reconnect.</b> Only a faulted session (timeout, I/O error, malformed frame) is reopened, and
/// only by the <i>next</i> send; the failing call throws its own exception. Caller cancellation never
/// faults the session (B2), so it never causes a reconnect.</para>
/// <para><b>Bounded.</b> Every <see cref="SendAsync"/> is bounded even if the caller passes no token
/// (ruling R9-3): opening (including the throttle wait and any wait behind another opener) is bounded by
/// <c>MinForwardOpenInterval + 4 × Timeout</c> (connect, RegisterSession, Forward Open, fallback Forward
/// Open), and the exchange by <c>2 × Timeout</c> (waiting behind one in-flight exchange, then its own).
/// Exceeding either surfaces as <see cref="AbTransportException"/>(<see cref="AbStatus.Timeout"/>).</para>
/// <para><b>Connection identity.</b> Every Forward Open attempt uses a fresh connection serial and T→O id,
/// so a reopen is never mistaken by the target for a duplicate of a connection it has not yet expired.</para>
/// </remarks>
public sealed class CipConnection : ICipChannel, IAsyncDisposable
{
/// <summary>Forward Open frames sent by every connection in the process (fallback included). Gate diagnostics.</summary>
internal static int ForwardOpenCount;
/// <summary>Forward Close requests put on the wire by every connection in the process. Gate diagnostics.</summary>
internal static int ForwardClosesSent;
/// <summary>Forward Close requests that got a reply within their budget. Gate diagnostics.</summary>
internal static int ForwardClosesAcked;
/// <summary>Channel sends by every connection in the process (keep-alives included). Gate diagnostics.</summary>
internal static int RequestsSent;
// Round-trip statistics of completed exchanges, process-wide (gate diagnostics: T21 latency attribution).
// Two Stopwatch reads and four interlocked ops per request; reset by the harness between timed passes.
private static long _exchangeCount;
private static long _exchangeTotalTicks;
private static long _exchangeMinTicks = long.MaxValue;
private static long _exchangeMaxTicks;
/// <summary>Round-trip statistics of the exchanges completed since the last <see cref="ResetExchangeStats"/>.</summary>
internal readonly record struct ExchangeStats(long Count, double MinMs, double MaxMs, double MeanMs);
/// <summary>Clears the process-wide round-trip statistics. Gate diagnostics.</summary>
internal static void ResetExchangeStats()
{
Interlocked.Exchange(ref _exchangeCount, 0);
Interlocked.Exchange(ref _exchangeTotalTicks, 0);
Interlocked.Exchange(ref _exchangeMinTicks, long.MaxValue);
Interlocked.Exchange(ref _exchangeMaxTicks, 0);
}
/// <summary>Snapshot of the process-wide round-trip statistics. Gate diagnostics.</summary>
internal static ExchangeStats ReadExchangeStats()
{
var count = Interlocked.Read(ref _exchangeCount);
if (count == 0) return new ExchangeStats(0, 0, 0, 0);
static double Ms(long ticks) => ticks * 1000.0 / Stopwatch.Frequency;
return new ExchangeStats(count, Ms(Interlocked.Read(ref _exchangeMinTicks)), Ms(Interlocked.Read(ref _exchangeMaxTicks)),
Ms(Interlocked.Read(ref _exchangeTotalTicks)) / count);
}
private static void RecordExchange(long ticks)
{
Interlocked.Increment(ref _exchangeCount);
Interlocked.Add(ref _exchangeTotalTicks, ticks);
long seen;
while (ticks < (seen = Interlocked.Read(ref _exchangeMinTicks))
&& Interlocked.CompareExchange(ref _exchangeMinTicks, ticks, seen) != seen) { }
while (ticks > (seen = Interlocked.Read(ref _exchangeMaxTicks))
&& Interlocked.CompareExchange(ref _exchangeMaxTicks, ticks, seen) != seen) { }
}
/// <summary>Open attempts (TCP connect + RegisterSession + Forward Open, a fallback open included) started by
/// every connection in the process — counted before any I/O, so a refused attempt counts too. Gate diagnostics.</summary>
internal static int OpenAttempts;
/// <summary>
/// Process-wide cap on <see cref="OpenAttempts"/>; <c>-1</c> (the default) is unlimited. An attempt beyond
/// the cap fails with <see cref="AbStatus.Abort"/> before any I/O — no TCP connect, no Forward Open. Only the
/// read-only differential harness (Task 18/21) sets it, so a production press that refuses or drops the
/// connection sees exactly one open attempt instead of one per remaining operation.
/// </summary>
internal static int OpenAttemptBudget = -1;
/// <summary>Get Attribute Single, Identity (class 1) instance 1 attribute 1 (Vendor ID).</summary>
internal static readonly byte[] KeepAliveRequest = [0x0E, 0x03, 0x20, 0x01, 0x24, 0x01, 0x30, 0x01];
/// <summary>Largest unconnected message a target is guaranteed to accept.</summary>
private const int UnconnectedMessageLimit = 504;
private sealed record Open(EncapsulationClient Client, ForwardOpenReply? Conn, ushort ConnectionSerial, int NegotiatedSize);
private readonly DeviceKey _key;
private readonly ConnectionOptions _o;
private readonly ForwardOpenThrottle _throttle;
private readonly TimeProvider _time;
private readonly Task _predecessorClosed;
private readonly byte[] _route;
private readonly object _sync = new();
// Cancels only the waits BEFORE an attempt (predecessor close, throttle interval), never an attempt
// already on the wire. Never disposed: it has no timer, and late registrations must stay legal.
private readonly CancellationTokenSource _disposing = new();
private readonly ITimer? _keepAlive;
private Task<Open>? _pendingOpen; // the one shared open in progress; under _sync
private Open? _open; // written under _sync (Volatile); read lock-free via Volatile
private int _disposed; // set under _sync; read lock-free via Volatile
private int _disposeStarted;
private long _lastSendTimestamp;
private int _keepAlivesSent;
private int _connectionSerial = Random.Shared.Next(1, ushort.MaxValue);
/// <param name="key">The device; <see cref="DeviceKey.CipPath"/> is the route (ignored for Direct).</param>
/// <param name="options">Routing, sizes, timeouts and keep-alive settings.</param>
/// <param name="throttle">The device's process-lifetime Forward Open throttle.</param>
/// <param name="time">Clock for the throttle, deadlines and keep-alive.</param>
/// <param name="predecessorClosed">Completes when the previous connection for the same key has
/// finished closing, so two connections to one device never overlap.</param>
internal CipConnection(DeviceKey key, ConnectionOptions options, ForwardOpenThrottle throttle, TimeProvider time,
Task? predecessorClosed = null)
{
_key = key ?? throw new ArgumentNullException(nameof(key));
_o = options ?? throw new ArgumentNullException(nameof(options));
_throttle = throttle ?? throw new ArgumentNullException(nameof(throttle));
_time = time ?? throw new ArgumentNullException(nameof(time));
_predecessorClosed = predecessorClosed ?? Task.CompletedTask;
// Direct ignores the path entirely (B3), so it is not even parsed.
_route = options.Routing == CipRouting.Direct ? [] : CipPath.EncodeRoute(key.CipPath);
_lastSendTimestamp = time.GetTimestamp();
if (options.Routing == CipRouting.Connected)
_keepAlive = time.CreateTimer(static s => ((CipConnection)s!).OnKeepAliveTimer(), this,
options.KeepAliveInterval, Timeout.InfiniteTimeSpan);
}
/// <summary>The options this connection was built with.</summary>
internal ConnectionOptions Options => _o;
/// <summary>Test hook: keep-alives started (counted synchronously when the timer decides to send).</summary>
internal int KeepAlivesSent => Volatile.Read(ref _keepAlivesSent);
private TimeSpan OpenBudget => _o.MinForwardOpenInterval + 4 * _o.Timeout;
private TimeSpan ExchangeBudget => 2 * _o.Timeout;
public int MaxRequestBytes => _o.Routing switch
{
CipRouting.Connected => (Volatile.Read(ref _open)?.NegotiatedSize ?? Math.Min(_o.ConnectionSize, UnconnectedMessageLimit)) - 2,
CipRouting.UnconnectedSend => UnconnectedMessageLimit - (12 + _route.Length),
_ => UnconnectedMessageLimit,
};
public async Task<ReadOnlyMemory<byte>> SendAsync(ReadOnlyMemory<byte> cipRequest, CancellationToken cancellationToken)
{
ThrowIfDisposed();
var open = await EnsureOpenAsync(cancellationToken).ConfigureAwait(false);
return await ExchangeAsync(open, cipRequest, cancellationToken).ConfigureAwait(false);
}
/// <summary>Returns the live (client, connection) pair, opening one if needed. The pair is returned
/// rather than re-read from fields afterwards, so a concurrent dispose can never hand the caller a
/// null (finding 16).</summary>
/// <remarks>All callers share ONE open, which runs under its own deadline only. A caller's token (or the
/// scheduler's stop token) ends that caller's <i>wait</i>, never the open itself: cancelling a Forward
/// Open mid-flight could leave a connection the target accepted but nobody ever closes. A completed
/// open is stored even if disposal began meanwhile, so <see cref="DisposeAsync"/> Forward Closes it.</remarks>
private Task<Open> EnsureOpenAsync(CancellationToken ct)
{
if (Volatile.Read(ref _open) is { Client.IsConnected: true } live) return Task.FromResult(live);
Task<Open> pending;
TaskCompletionSource<Open>? started = null;
lock (_sync)
{
ThrowIfDisposed();
if (_open is { Client.IsConnected: true } current) return Task.FromResult(current);
if (_pendingOpen is null)
{
started = new TaskCompletionSource<Open>(TaskCreationOptions.RunContinuationsAsynchronously);
_pendingOpen = started.Task;
}
pending = _pendingOpen;
}
if (started is not null)
{
// Every waiter may have abandoned the open (its own token); observe a failure so it never
// surfaces as UnobservedTaskException.
_ = started.Task.ContinueWith(static t => _ = t.Exception, CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
_ = RunOpenAsync(started);
}
return pending.WaitAsync(ct);
}
/// <summary>The shared open. Bounded by <see cref="OpenBudget"/> (ruling R9-3); its outcome, success or
/// failure, goes to every caller waiting on it.</summary>
private async Task RunOpenAsync(TaskCompletionSource<Open> result)
{
using var deadline = new CancellationTokenSource(OpenBudget, _time);
using var beforeAttempt = CancellationTokenSource.CreateLinkedTokenSource(deadline.Token, _disposing.Token);
Open? opened = null;
Exception? error = null;
try
{
Open? stale;
lock (_sync)
{
stale = _open;
Volatile.Write(ref _open, null);
}
if (stale is not null) await stale.Client.DisposeAsync().ConfigureAwait(false); // faulted: no UnRegister
await _predecessorClosed.WaitAsync(beforeAttempt.Token).ConfigureAwait(false);
opened = await _throttle.RunAsync(_ => OpenAsync(deadline.Token), beforeAttempt.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
error = Volatile.Read(ref _disposed) != 0
? new AbTransportException(AbStatus.Abort, "Connection disposed.", ex)
: ex is OperationCanceledException && deadline.IsCancellationRequested
? new AbTransportException(AbStatus.Timeout,
$"Opening {_key.Host}:{_key.Port} did not complete within {OpenBudget.TotalMilliseconds:F0} ms.", ex)
: ex;
}
lock (_sync)
{
if (opened is not null) Volatile.Write(ref _open, opened);
_pendingOpen = null;
}
if (opened is not null) result.SetResult(opened);
else result.SetException(error!);
}
private async Task<Open> OpenAsync(CancellationToken ct)
{
ThrowIfDisposed(); // disposal may have begun while this attempt waited out the throttle
var attempt = Interlocked.Increment(ref OpenAttempts);
var budget = Volatile.Read(ref OpenAttemptBudget);
if (budget >= 0 && attempt > budget)
throw new AbTransportException(AbStatus.Abort,
$"Open attempt {attempt} to {_key.Host}:{_key.Port} refused before any I/O: the process-wide open-attempt budget of {budget} is spent.");
var client = await EncapsulationClient.ConnectAsync(_key.Host, _key.Port, _o.Timeout, ct).ConfigureAwait(false);
if (Volatile.Read(ref _disposed) != 0)
{
// Disposal began during the TCP connect / RegisterSession: never put a Forward Open on the wire.
await client.DisposeAsync().ConfigureAwait(false);
throw new AbTransportException(AbStatus.Abort, "Connection disposed.");
}
if (_o.Routing != CipRouting.Connected) return new Open(client, null, 0, UnconnectedMessageLimit);
try
{
var serial = NextConnectionSerial();
var toId = (uint)Random.Shared.NextInt64(1, uint.MaxValue);
var large = _o.ConnectionSize > 511;
var size = _o.ConnectionSize;
var reply = await ForwardOpenAsync(client, large, size, serial, toId, ct).ConfigureAwait(false);
if (large && ConnectionManagerServices.ShouldFallBackToRegularOpen(reply))
{
// Pre-v20 Logix / small-buffer targets: retry once, inside this same throttled attempt.
size = Math.Min(_o.ConnectionSize, UnconnectedMessageLimit);
reply = await ForwardOpenAsync(client, false, size, serial, toId, ct).ConfigureAwait(false);
}
return new Open(client, ConnectionManagerServices.ParseForwardOpenReply(reply), serial, size);
}
catch
{
await client.DisposeAsync().ConfigureAwait(false);
throw;
}
}
private async Task<CipReply> ForwardOpenAsync(EncapsulationClient client, bool large, int size, ushort serial,
uint toId, CancellationToken ct)
{
var p = new ForwardOpenParams(large, toId, serial, _o.VendorId, _o.OriginatorSerial, _o.TimeoutMultiplier,
_o.RpiMicros, size, _route);
var frame = ConnectionManagerServices.ForwardOpen(p);
Interlocked.Increment(ref ForwardOpenCount);
return CipReply.Parse(await client.SendRRDataAsync(frame, _o.Timeout, ct).ConfigureAwait(false));
}
private ushort NextConnectionSerial()
{
var s = (ushort)Interlocked.Increment(ref _connectionSerial);
return s == 0 ? (ushort)Interlocked.Increment(ref _connectionSerial) : s;
}
/// <summary>One request over an established pair, bounded by <see cref="ExchangeBudget"/>.</summary>
private async Task<ReadOnlyMemory<byte>> ExchangeAsync(Open open, ReadOnlyMemory<byte> cip, CancellationToken ct)
{
Interlocked.Increment(ref RequestsSent);
Interlocked.Exchange(ref _lastSendTimestamp, _time.GetTimestamp());
using var deadline = new CancellationTokenSource(ExchangeBudget, _time);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, deadline.Token);
var started = Stopwatch.GetTimestamp();
try
{
var reply = _o.Routing switch
{
CipRouting.Connected => await open.Client.SendUnitDataAsync(open.Conn!.OTConnectionId, cip, _o.Timeout, linked.Token).ConfigureAwait(false),
CipRouting.UnconnectedSend => await open.Client.SendRRDataAsync(ConnectionManagerServices.UnconnectedSend(cip.Span, _route), _o.Timeout, linked.Token).ConfigureAwait(false),
_ => await open.Client.SendRRDataAsync(cip, _o.Timeout, linked.Token).ConfigureAwait(false),
};
RecordExchange(Stopwatch.GetTimestamp() - started);
return reply;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested && deadline.IsCancellationRequested)
{
throw new AbTransportException(AbStatus.Timeout,
$"Request to {_key.Host}:{_key.Port} did not complete within {ExchangeBudget.TotalMilliseconds:F0} ms.");
}
}
/// <summary>One-shot timer, re-armed after every tick so the longest idle gap is one
/// <see cref="ConnectionOptions.KeepAliveInterval"/> (half the connection expiry), not two.</summary>
private void OnKeepAliveTimer()
{
if (Volatile.Read(ref _disposed) != 0) return;
var interval = _o.KeepAliveInterval;
var open = Volatile.Read(ref _open);
if (open is not { Client.IsConnected: true })
{
// Nothing to keep alive, and a keep-alive must never be what reopens a connection.
Rearm(interval);
return;
}
var idle = _time.GetElapsedTime(Interlocked.Read(ref _lastSendTimestamp));
if (idle < interval)
{
Rearm(interval - idle);
return;
}
Interlocked.Increment(ref _keepAlivesSent);
_ = KeepAliveAsync(open);
}
private async Task KeepAliveAsync(Open open)
{
try { await ExchangeAsync(open, KeepAliveRequest, CancellationToken.None).ConfigureAwait(false); }
catch (Exception)
{
// A timeout or I/O failure has already faulted the session; the next real send reconnects
// through the throttle. Nothing to report here: the keep-alive has no caller.
}
finally { Rearm(_o.KeepAliveInterval); }
}
private void Rearm(TimeSpan due)
{
if (Volatile.Read(ref _disposed) != 0) return;
try { _keepAlive?.Change(due, Timeout.InfiniteTimeSpan); }
catch (ObjectDisposedException) { /* disposed concurrently */ }
}
private void ThrowIfDisposed()
{
if (Volatile.Read(ref _disposed) != 0)
throw new AbTransportException(AbStatus.Abort, "Connection disposed.");
}
/// <summary>Stops the keep-alive, sends Forward Close (Connected only) and closes the session, whose
/// own dispose sends UnRegisterSession. Later sends throw <see cref="AbStatus.Abort"/>.</summary>
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) return;
// Under the lock, so no caller can start a new open after this point. Then cancel the shared
// open's pre-attempt waits; an attempt already on the wire is allowed to finish (bounded by
// OpenBudget) so that whatever it opened is Forward Closed below.
Task<Open>? pending;
lock (_sync)
{
Volatile.Write(ref _disposed, 1);
pending = _pendingOpen;
}
await _disposing.CancelAsync().ConfigureAwait(false);
if (pending is not null)
{
try { await pending.ConfigureAwait(false); }
catch (Exception) { /* its callers have the error; nothing was opened */ }
}
Open? open;
lock (_sync)
{
open = _open;
Volatile.Write(ref _open, null);
}
if (_keepAlive is not null) await _keepAlive.DisposeAsync().ConfigureAwait(false);
if (open is null) return;
if (open.Conn is not null && open.Client.IsConnected)
{
try
{
var budget = TimeSpan.FromSeconds(1);
using var cts = new CancellationTokenSource(budget, _time);
Interlocked.Increment(ref ForwardClosesSent);
await open.Client.SendRRDataAsync(
ConnectionManagerServices.ForwardClose(open.ConnectionSerial, _o.VendorId, _o.OriginatorSerial, _route),
budget, cts.Token).ConfigureAwait(false);
Interlocked.Increment(ref ForwardClosesAcked);
}
catch (Exception ex) when (ex is AbTransportException or OperationCanceledException)
{
// The target expires the connection after RPI × multiplier anyway.
}
}
await open.Client.DisposeAsync().ConfigureAwait(false);
}
}
@@ -0,0 +1,109 @@
using System.Globalization;
using System.Text;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
public static class CipPath
{
/// <summary>Route path from "port,link[,port,link…]". Links that are not a byte value are IP
/// addresses, encoded as extended link-address port segments.</summary>
public static byte[] EncodeRoute(string cipPath)
{
if (string.IsNullOrWhiteSpace(cipPath)) return [];
var w = new CipWriter();
var parts = cipPath.Split(',', StringSplitOptions.TrimEntries);
if (parts.Length % 2 != 0) throw new ArgumentException($"CIP path '{cipPath}' must be port,link pairs.", nameof(cipPath));
for (var i = 0; i < parts.Length; i += 2)
{
var port = int.Parse(parts[i], CultureInfo.InvariantCulture);
var link = parts[i + 1];
// A link is a slot byte or an IP address/hostname; neither contains whitespace. Without this a
// typo like "1,2 3" would be sent as an ASCII link "2 3" instead of failing here.
if (link.Length == 0 || link.Any(char.IsWhiteSpace))
throw new ArgumentException($"CIP path '{cipPath}' has an empty or malformed link '{link}'.", nameof(cipPath));
var isByte = byte.TryParse(link, NumberStyles.None, CultureInfo.InvariantCulture, out var linkByte);
if (port < 15)
{
if (isByte) w.U8((byte)port).U8(linkByte);
else { var a = Encoding.ASCII.GetBytes(link); w.U8((byte)(0x10 | port)).U8((byte)a.Length).Bytes(a).PadToEven(); }
}
else
{
if (isByte) w.U8(0x0F).U16((ushort)port).U8(linkByte);
else { var a = Encoding.ASCII.GetBytes(link); w.U8(0x1F).U8((byte)a.Length).U16((ushort)port).Bytes(a).PadToEven(); }
}
}
return w.ToArray();
}
/// <summary>Splits a trailing purely numeric member ("Tag.5") into the tag name and bit index.</summary>
public static (string Name, int? Bit) SplitBitSelector(string tagName)
{
var dot = tagName.LastIndexOf('.');
if (dot > 0 && dot < tagName.Length - 1 && tagName[(dot + 1)..].All(char.IsAsciiDigit))
return (tagName[..dot], int.Parse(tagName[(dot + 1)..], CultureInfo.InvariantCulture));
return (tagName, null);
}
public static byte[] EncodeSymbolic(string tagName)
{
if (SplitBitSelector(tagName).Bit is not null)
throw new ArgumentException($"'{tagName}' ends in a bit selector; strip it with SplitBitSelector first.", nameof(tagName));
var w = new CipWriter();
foreach (var part in SplitMembers(tagName))
{
var bracket = part.IndexOf('[');
var name = bracket < 0 ? part : part[..bracket];
var a = Encoding.ASCII.GetBytes(name);
w.U8(0x91).U8((byte)a.Length).Bytes(a).PadToEven();
if (bracket >= 0)
{
foreach (var idxText in part[(bracket + 1)..part.IndexOf(']')].Split(',', StringSplitOptions.TrimEntries))
{
var idx = uint.Parse(idxText, CultureInfo.InvariantCulture);
if (idx < 0x100) w.U8(0x28).U8((byte)idx);
else if (idx < 0x10000) w.U8(0x29).U8(0).U16((ushort)idx);
else w.U8(0x2A).U8(0).U32(idx);
}
}
}
return w.ToArray();
}
/// <summary>Split on '.' outside brackets, keeping "Program:X" whole.</summary>
private static IEnumerable<string> SplitMembers(string name)
{
var depth = 0; var start = 0;
for (var i = 0; i < name.Length; i++)
{
if (name[i] == '[') depth++;
else if (name[i] == ']') depth--;
else if (name[i] == '.' && depth == 0) { yield return name[start..i]; start = i + 1; }
}
yield return name[start..];
}
public static byte[] EncodeClassInstance(ushort cls, uint instance)
{
var w = new CipWriter();
if (cls < 0x100) w.U8(0x20).U8((byte)cls); else w.U8(0x21).U8(0).U16(cls);
if (instance < 0x100) w.U8(0x24).U8((byte)instance);
else if (instance < 0x10000) w.U8(0x25).U8(0).U16((ushort)instance);
else w.U8(0x26).U8(0).U32(instance);
return w.ToArray();
}
/// <summary>Prefixes the path with its size in 16-bit words.</summary>
public static byte[] WithPathSize(byte[] segments)
{
var w = new CipWriter();
w.U8((byte)(segments.Length / 2)).Bytes(segments);
return w.ToArray();
}
/// <summary>Message Router (class 2, instance 1).</summary>
public static readonly byte[] MessageRouter = [0x20, 0x02, 0x24, 0x01];
/// <summary>Connection Manager (class 6, instance 1).</summary>
public static readonly byte[] ConnectionManager = [0x20, 0x06, 0x24, 0x01];
}
@@ -0,0 +1,22 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>A parsed CIP message-router reply: service (request code, 0x80 bit stripped), general
/// status, extended status words, and the reply data.</summary>
public sealed record CipReply(byte Service, byte GeneralStatus, ushort[] ExtendedStatus, ReadOnlyMemory<byte> Data)
{
public AbStatus Status => AbStatusCodes.FromCip(GeneralStatus, ExtendedStatus);
public static CipReply Parse(ReadOnlyMemory<byte> raw)
{
var r = new CipReader(raw.Span);
var service = (byte)(r.U8() & 0x7F);
r.U8(); // reserved
var general = r.U8();
var extWords = r.U8();
var ext = new ushort[extWords];
for (var i = 0; i < extWords; i++) ext[i] = r.U16();
var data = raw[r.Position..];
return new CipReply(service, general, ext, data);
}
}
@@ -0,0 +1,75 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>Parameters for a (Large) Forward Open. <see cref="ConnectionSize"/> is the maximum CIP
/// connection payload in bytes (e.g. 4002 for Large, 504 for regular).</summary>
public sealed record ForwardOpenParams(
bool Large, uint TOConnectionId, ushort ConnectionSerial, ushort VendorId, uint OriginatorSerial,
byte TimeoutMultiplier, uint RpiMicros, int ConnectionSize, byte[] Route);
public sealed record ForwardOpenReply(uint OTConnectionId, uint TOConnectionId, uint OTApiMicros, uint TOApiMicros);
/// <summary>Connection Manager (class 6) request builders and reply parsers.</summary>
public static class ConnectionManagerServices
{
/// <summary>Priority/time-tick byte.</summary>
public const byte PriorityTick = 0x0A;
/// <summary>Timeout ticks. The brief proposed 0x0E; every libplctag capture (Forward Open, Forward
/// Close, Unconnected Send) carries 0x05, and the capture wins.</summary>
public const byte TimeoutTicks = 0x05;
public static byte[] UnconnectedSend(ReadOnlySpan<byte> embedded, ReadOnlySpan<byte> route)
{
var w = new CipWriter().U8(0x52).U8(2).Bytes(CipPath.ConnectionManager)
.U8(PriorityTick).U8(TimeoutTicks).U16((ushort)embedded.Length).Bytes(embedded);
if ((embedded.Length & 1) == 1) w.U8(0);
return w.U8((byte)(route.Length / 2)).U8(0).Bytes(route).ToArray();
}
public static byte[] ForwardOpen(ForwardOpenParams p)
{
if (p.ConnectionSize < 1 || p.ConnectionSize > (p.Large ? 0xFFFF : 0x1FF))
throw new ArgumentOutOfRangeException(nameof(p), p.ConnectionSize,
p.Large ? "Large connection size must be 1..65535." : "Regular connection size must be 1..511.");
var connPath = new CipWriter().Bytes(p.Route).Bytes(CipPath.MessageRouter).ToArray();
var w = new CipWriter().U8(p.Large ? (byte)0x5B : (byte)0x54).U8(2).Bytes(CipPath.ConnectionManager)
.U8(PriorityTick).U8(TimeoutTicks)
.U32(0).U32(p.TOConnectionId).U16(p.ConnectionSerial).U16(p.VendorId).U32(p.OriginatorSerial)
.U8(p.TimeoutMultiplier).U8(0).U8(0).U8(0);
WriteSide(w, p);
WriteSide(w, p);
return w.U8(0xA3).U8((byte)(connPath.Length / 2)).Bytes(connPath).ToArray();
}
private static void WriteSide(CipWriter w, ForwardOpenParams p)
{
w.U32(p.RpiMicros);
if (p.Large) w.U32(0x42000000u | (uint)p.ConnectionSize);
else w.U16((ushort)(0x4200 | (p.ConnectionSize & 0x1FF)));
}
public static ForwardOpenReply ParseForwardOpenReply(CipReply reply)
{
if (reply.GeneralStatus != 0)
throw new AbTransportException(reply.Status == AbStatus.BadReply ? AbStatus.BadConnection : reply.Status,
$"Forward Open refused: general 0x{reply.GeneralStatus:X2}, extended {string.Join(",", reply.ExtendedStatus.Select(e => $"0x{e:X4}"))}.");
var r = new CipReader(reply.Data.Span);
var ot = r.U32(); var to = r.U32(); r.U16(); r.U16(); r.U32();
return new ForwardOpenReply(ot, to, r.U32(), r.U32());
}
/// <summary>True when a refused Large Forward Open should be retried as a regular one: general 0x08
/// (Service Not Supported — pre-v20 Logix) or general 0x01 / extended 0x0109 (invalid connection size).</summary>
public static bool ShouldFallBackToRegularOpen(CipReply r) =>
r.GeneralStatus == 0x08
|| (r.GeneralStatus == 0x01 && r.ExtendedStatus.Length > 0 && r.ExtendedStatus[0] == 0x0109);
public static byte[] ForwardClose(ushort connectionSerial, ushort vendorId, uint originatorSerial, ReadOnlySpan<byte> route)
{
var connPath = new CipWriter().Bytes(route).Bytes(CipPath.MessageRouter).ToArray();
return new CipWriter().U8(0x4E).U8(2).Bytes(CipPath.ConnectionManager).U8(PriorityTick).U8(TimeoutTicks)
.U16(connectionSerial).U16(vendorId).U32(originatorSerial)
.U8((byte)(connPath.Length / 2)).U8(0).Bytes(connPath).ToArray();
}
}
@@ -0,0 +1,100 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>How CIP requests reach the target's message router (review finding B3; the captures decide
/// each family's mode).</summary>
public enum CipRouting
{
/// <summary>Forward Open a class-3 connection, then SendUnitData. Logix and Micro800 (empty route).</summary>
Connected,
/// <summary>SendRRData wrapping each request in Unconnected Send (0x52) carrying the route.</summary>
UnconnectedSend,
/// <summary>SendRRData carrying the request bare; any configured CIP path is ignored (PCCC families).</summary>
Direct,
}
/// <summary>Per-device connection settings. Validated on assignment so an unusable value fails at the
/// configuration seam rather than on the wire.</summary>
public sealed record ConnectionOptions
{
/// <summary>Press safety floor: no setting may let Forward Opens to one device start closer together.</summary>
public static readonly TimeSpan MinimumForwardOpenInterval = TimeSpan.FromSeconds(1);
/// <summary>Originator serial shared by every connection this process opens.</summary>
private static readonly uint ProcessOriginatorSerial = (uint)Random.Shared.Next(1, int.MaxValue);
private readonly TimeSpan _timeout = TimeSpan.FromSeconds(5);
private readonly int _connectionSize = 4002;
private readonly TimeSpan _minForwardOpenInterval = MinimumForwardOpenInterval;
private readonly uint _rpiMicros = 2_000_000;
private readonly byte _timeoutMultiplier = 1;
private readonly TimeSpan? _keepAliveInterval;
public CipRouting Routing { get; init; } = CipRouting.Connected;
/// <summary>Pack queued requests into Multiple Service Packets (the scheduler's switch).</summary>
public bool AllowPacking { get; init; }
/// <summary>Bound on each single exchange (TCP connect, RegisterSession, Forward Open, request).
/// Must be finite and positive (ruling R9-2): an infinite timeout would let an abandoned exchange
/// hold the session's single-flight gate forever.</summary>
public TimeSpan Timeout
{
get => _timeout;
init => _timeout = value > TimeSpan.Zero && value != System.Threading.Timeout.InfiniteTimeSpan
? value
: throw new ArgumentOutOfRangeException(nameof(Timeout), value, "Timeout must be finite and positive.");
}
/// <summary>Requested connection size in bytes. Above 511 the open is a Large Forward Open (0x5B).</summary>
public int ConnectionSize
{
get => _connectionSize;
init => _connectionSize = value is >= 1 and <= ushort.MaxValue
? value
: throw new ArgumentOutOfRangeException(nameof(ConnectionSize), value, "Connection size must be 1..65535.");
}
/// <summary>Minimum time between Forward Open attempt starts for one device (≥ 1 s, press safety).</summary>
public TimeSpan MinForwardOpenInterval
{
get => _minForwardOpenInterval;
init => _minForwardOpenInterval = value >= MinimumForwardOpenInterval && value != System.Threading.Timeout.InfiniteTimeSpan
? value
: throw new ArgumentOutOfRangeException(nameof(MinForwardOpenInterval), value, "Must be finite and at least 1 s (press safety).");
}
/// <summary>Requested packet interval for both directions, in microseconds.</summary>
public uint RpiMicros
{
get => _rpiMicros;
init => _rpiMicros = value > 0 ? value : throw new ArgumentOutOfRangeException(nameof(RpiMicros), value, "RPI must be positive.");
}
/// <summary>Encoded connection timeout multiplier: the connection expires after RPI × (4 &lt;&lt; value).
/// The default 1 means ×8, so 2 s × 8 = 16 s.</summary>
public byte TimeoutMultiplier
{
get => _timeoutMultiplier;
init => _timeoutMultiplier = value <= 7 ? value : throw new ArgumentOutOfRangeException(nameof(TimeoutMultiplier), value, "Multiplier must be 0..7.");
}
/// <summary>How long an idle connected session waits before the target would drop it.</summary>
public TimeSpan ConnectionExpiry => TimeSpan.FromTicks(RpiMicros * 10L * (4L << TimeoutMultiplier));
/// <summary>Idle time after which a keep-alive is sent (Connected only). Defaults to half the
/// connection expiry: RPI × 8 / 2 = 8 s.</summary>
public TimeSpan KeepAliveInterval
{
get => _keepAliveInterval ?? ConnectionExpiry / 2;
init => _keepAliveInterval = value > TimeSpan.Zero && value != System.Threading.Timeout.InfiniteTimeSpan
? value
: throw new ArgumentOutOfRangeException(nameof(KeepAliveInterval), value, "Keep-alive interval must be finite and positive.");
}
public ushort VendorId { get; init; } = 0x1337;
/// <summary>Random per process; together with the vendor id and connection serial it names a connection.</summary>
public uint OriginatorSerial { get; init; } = ProcessOriginatorSerial;
}
@@ -0,0 +1,37 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>
/// Identity of one physical CIP connection: exactly one <see cref="CipConnection"/> (and one Forward Open
/// throttle) exists per key (press safety). <see cref="Host"/> and <see cref="PlcAttribute"/> compare
/// case-insensitively; <see cref="Port"/> compares exactly; <see cref="CipPath"/> is canonicalised (each
/// comma-separated part trimmed, so <c>"1,0"</c> and <c>" 1, 0 "</c> are the same device) and then
/// compares exactly. Whitespace INSIDE a part is kept, so a typo such as <c>"1,2 3"</c> still fails loudly in
/// <see cref="Cip.CipPath.EncodeRoute"/> instead of silently becoming slot 23.
/// </summary>
public sealed record DeviceKey(string Host, int Port, string CipPath, string PlcAttribute)
{
private readonly string _cipPath = Canonicalise(CipPath);
/// <summary>The route with each part trimmed. Canonicalised on construction and on <c>with</c>.</summary>
public string CipPath
{
get => _cipPath;
init => _cipPath = Canonicalise(value);
}
private static string Canonicalise(string? path) =>
string.IsNullOrWhiteSpace(path) ? "" : string.Join(',', path.Split(',').Select(p => p.Trim()));
public bool Equals(DeviceKey? other) =>
other is not null
&& string.Equals(Host, other.Host, StringComparison.OrdinalIgnoreCase)
&& Port == other.Port
&& string.Equals(CipPath, other.CipPath, StringComparison.Ordinal)
&& string.Equals(PlcAttribute, other.PlcAttribute, StringComparison.OrdinalIgnoreCase);
public override int GetHashCode() => HashCode.Combine(
StringComparer.OrdinalIgnoreCase.GetHashCode(Host),
Port,
StringComparer.Ordinal.GetHashCode(CipPath),
StringComparer.OrdinalIgnoreCase.GetHashCode(PlcAttribute));
}
@@ -0,0 +1,61 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>Press safety: at most one Forward Open attempt in flight per device and a minimum
/// interval between attempt starts. A misconfigured client once drove ~15 Forward Opens/s at a
/// production press (ignitionoee vite-press-oee-bind.md, 2026-09-23); this is what prevents it.</summary>
/// <remarks>One instance per <see cref="DeviceKey"/>, kept by <see cref="DeviceSessionPool"/> for the
/// process lifetime so a reconnect after the last lease was released is still limited. Its semaphore is
/// therefore never disposed. The interval is measured from the previous attempt's <i>start</i>, whatever
/// that attempt's outcome.</remarks>
public sealed class ForwardOpenThrottle
{
private readonly SemaphoreSlim _inFlight = new(1, 1);
private readonly TimeSpan _minInterval;
private readonly TimeProvider _time;
private long? _lastStartTimestamp; // monotonic (TimeProvider.GetTimestamp); only touched while holding _inFlight
/// <remarks>Internal: the only legitimate owner is <see cref="DeviceSessionPool"/>, which keeps exactly
/// one per device. A second throttle for the same device would halve the guaranteed interval.</remarks>
internal ForwardOpenThrottle(TimeSpan minInterval, TimeProvider time)
{
if (minInterval < TimeSpan.Zero || minInterval == Timeout.InfiniteTimeSpan)
throw new ArgumentOutOfRangeException(nameof(minInterval), minInterval, "Interval must be finite and non-negative.");
_minInterval = minInterval;
_time = time ?? throw new ArgumentNullException(nameof(time));
}
/// <summary>Run one open attempt once the throttle allows it. <paramref name="ct"/> bounds only the wait
/// BEFORE the attempt (queued behind an in-flight attempt, or the interval): when it fires there, this
/// throws <see cref="AbTransportException"/>(<see cref="AbStatus.BadConnection"/>, "Forward Open
/// throttled"). <paramref name="ct"/> is also passed to <paramref name="open"/>, which may ignore it:
/// <see cref="CipConnection"/> does, running the attempt under its own open deadline so that an accepted
/// Forward Open is never abandoned without a Forward Close.</summary>
public async Task<T> RunAsync<T>(Func<CancellationToken, Task<T>> open, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(open);
try { await _inFlight.WaitAsync(ct).ConfigureAwait(false); }
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{ throw Throttled("another attempt is in flight"); }
try
{
if (_lastStartTimestamp is { } last)
{
// Monotonic: a wall-clock step (NTP, DST, an operator) can never shorten the interval.
var wait = _minInterval - _time.GetElapsedTime(last);
if (wait > TimeSpan.Zero)
{
try { await Task.Delay(wait, _time, ct).ConfigureAwait(false); }
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{ throw Throttled("the minimum interval had not elapsed"); }
}
}
_lastStartTimestamp = _time.GetTimestamp();
return await open(ct).ConfigureAwait(false);
}
finally { _inFlight.Release(); }
}
private static AbTransportException Throttled(string why) =>
new(AbStatus.BadConnection, $"Forward Open throttled; caller deadline reached before {why}.");
}
@@ -0,0 +1,13 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>A path to a CIP message router: connected (Forward Open) or unconnected (UCMM). Task 9
/// implements it; Task 10's scheduler and the native adapters consume it.</summary>
public interface ICipChannel
{
/// <summary>Largest CIP request (service byte through end of data) one call may carry.</summary>
int MaxRequestBytes { get; }
/// <summary>Send one CIP request and return the raw CIP reply bytes (reply-service byte first).
/// Throws <see cref="AbTransportException"/> on transport failure or timeout.</summary>
Task<ReadOnlyMemory<byte>> SendAsync(ReadOnlyMemory<byte> cipRequest, CancellationToken cancellationToken);
}
@@ -0,0 +1,47 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>Multiple Service Packet (service 0x0A on the Message Router).</summary>
public static class MultipleServicePacket
{
/// <summary>Service + path size + Message Router path.</summary>
private const int HeaderSize = 6;
public static byte[] Build(IReadOnlyList<byte[]> requests)
{
var w = new CipWriter().U8(0x0A).U8(2).Bytes(CipPath.MessageRouter).U16((ushort)requests.Count);
var offset = 2 + 2 * requests.Count; // measured from the start of the count field
foreach (var r in requests) { w.U16((ushort)offset); offset += r.Length; }
foreach (var r in requests) w.Bytes(r);
return w.ToArray();
}
/// <summary>Exact byte size of an MSP carrying requests of the given sizes.</summary>
public static int BuildSize(IReadOnlyList<int> requestSizes)
{
var total = HeaderSize + 2 + 2 * requestSizes.Count;
foreach (var s in requestSizes) total += s;
return total;
}
/// <summary>Splits an MSP reply (the outer reply's data) into the embedded replies.</summary>
public static IReadOnlyList<CipReply> Parse(CipReply reply, int count)
{
var data = reply.Data;
var r = new CipReader(data.Span);
var n = r.U16();
if (n != count)
throw new AbTransportException(AbStatus.BadReply, $"MSP reply carries {n} replies, expected {count}.");
var offsets = new int[n];
for (var i = 0; i < n; i++) offsets[i] = r.U16();
var result = new CipReply[n];
for (var i = 0; i < n; i++)
{
var end = i + 1 < n ? offsets[i + 1] : data.Length;
if (offsets[i] < 2 + 2 * n || end < offsets[i] || end > data.Length)
throw new AbTransportException(AbStatus.BadReply, $"MSP reply offset {offsets[i]}..{end} is out of range.");
result[i] = CipReply.Parse(data[offsets[i]..end]);
}
return result;
}
}
@@ -0,0 +1,309 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
/// <summary>
/// Per-connection CIP request scheduler. Keeps exactly one request in flight on its
/// <see cref="ICipChannel"/>; requests that arrive while one is in flight queue, and when the channel
/// frees the scheduler packs as many of them (FIFO) as fit in <see cref="ICipChannel.MaxRequestBytes"/>
/// into one Multiple Service Packet. Each waiter receives its own embedded <see cref="CipReply"/>.
/// </summary>
/// <remarks>
/// One scheduler per device connection, never per tag (review finding B1). The channel is always
/// called with the scheduler's own stop token, never a waiter's, so one caller's cancellation can
/// never abort or fault a shared exchange (B2). A waiter cancelled while queued is removed and never
/// sent; one cancelled while its batch is in flight is abandoned and the batch's other waiters still
/// complete. There is deliberately no batching delay window: packing falls out of the single pump
/// being busy on the channel while requests accumulate.
/// </remarks>
public sealed class RequestScheduler : IAsyncDisposable
{
private const byte MultipleServiceCode = 0x0A;
private const byte EmbeddedServiceError = 0x1E;
private sealed class Pending(byte[] request, bool exclusive)
{
private bool _completed;
private CancellationTokenRegistration _registration;
private bool _hasRegistration;
public byte[] Request { get; } = request;
/// <summary>True when this request must never share a Multiple Service Packet with another.</summary>
public bool Exclusive { get; } = exclusive;
public TaskCompletionSource<CipReply> Tcs { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public LinkedListNode<Pending>? Node { get; set; }
/// <summary>Stores the waiter's registration; disposes it at once if the waiter already completed.</summary>
public void SetRegistration(CancellationTokenRegistration registration, RequestScheduler owner)
{
lock (this)
{
if (!_completed)
{
_registration = registration;
_hasRegistration = true;
return;
}
}
registration.Dispose();
Interlocked.Decrement(ref owner._liveRegistrations);
}
/// <summary>Marks the waiter complete exactly once and releases its registration (finding 10).
/// Returns false if it was already completed.</summary>
private bool Finish(RequestScheduler owner)
{
CancellationTokenRegistration registration;
bool hasRegistration;
lock (this)
{
if (_completed) return false;
_completed = true;
registration = _registration;
hasRegistration = _hasRegistration;
_registration = default;
_hasRegistration = false;
}
if (hasRegistration)
{
// Safe from inside the registration's own callback: Dispose does not wait on the
// callback that is currently executing on this thread.
registration.Dispose();
Interlocked.Decrement(ref owner._liveRegistrations);
}
return true;
}
public void Succeed(CipReply reply, RequestScheduler owner)
{
if (Finish(owner)) Tcs.TrySetResult(reply);
}
public void Fail(Exception ex, RequestScheduler owner)
{
if (Finish(owner)) Tcs.TrySetException(ex);
}
public void Cancel(CancellationToken ct, RequestScheduler owner)
{
if (Finish(owner)) Tcs.TrySetCanceled(ct);
}
}
private readonly ICipChannel _channel;
private readonly bool _allowPacking;
private readonly LinkedList<Pending> _queue = new();
private readonly object _lock = new();
private readonly SemaphoreSlim _signal = new(0);
private readonly CancellationTokenSource _stop = new();
private readonly Task _pump;
private bool _disposed;
private int _liveRegistrations;
private int _cancellationCallbacksRun;
public RequestScheduler(ICipChannel channel, bool allowPacking)
{
_channel = channel ?? throw new ArgumentNullException(nameof(channel));
_allowPacking = allowPacking;
_pump = Task.Run(PumpAsync);
}
/// <summary>Test hook: waiter cancellation registrations that have not yet been disposed.</summary>
internal int LiveRegistrations => Volatile.Read(ref _liveRegistrations);
/// <summary>Test hook: waiter cancellation callbacks that have actually run. A disposed
/// registration never runs, so this stays flat when a long-lived token is cancelled later.</summary>
internal int CancellationCallbacksRun => Volatile.Read(ref _cancellationCallbacksRun);
/// <summary>Test hook: requests still queued (not yet taken into a batch).</summary>
internal int QueuedCount { get { lock (_lock) return _queue.Count; } }
/// <summary>
/// Queue one CIP request. The returned reply carries the service's own general status; a non-zero
/// status is not an exception here. Faults with <see cref="AbTransportException"/> when the channel
/// or the MSP envelope fails, or the scheduler is disposed; cancels only when <paramref name="ct"/> fires.
/// </summary>
/// <param name="request">The CIP request.</param>
/// <param name="ct">Cancels this waiter only.</param>
/// <param name="exclusive">When true the request is sent alone, never packed into a Multiple Service
/// Packet: for requests whose reply can fill the whole connection (the packer budgets request bytes only).</param>
public Task<CipReply> SendAsync(byte[] request, CancellationToken ct, bool exclusive = false)
{
ArgumentNullException.ThrowIfNull(request);
if (ct.IsCancellationRequested) return Task.FromCanceled<CipReply>(ct);
var p = new Pending(request, exclusive);
lock (_lock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
p.Node = _queue.AddLast(p);
}
if (ct.CanBeCanceled)
{
Interlocked.Increment(ref _liveRegistrations);
var registration = ct.Register(static state =>
{
var (self, pending, token) = ((RequestScheduler, Pending, CancellationToken))state!;
Interlocked.Increment(ref self._cancellationCallbacksRun);
lock (self._lock)
{
if (pending.Node?.List is not null) self._queue.Remove(pending.Node);
}
pending.Cancel(token, self);
}, (this, p, ct));
p.SetRegistration(registration, this);
}
lock (_lock)
{
if (!_disposed) _signal.Release();
}
return p.Tcs.Task;
}
/// <summary>Moves the next batch from the queue into <paramref name="batch"/>. Filling a caller-owned
/// list means a throw part-way through still leaves every taken waiter reachable for faulting.</summary>
private void TakeBatch(List<Pending> batch)
{
lock (_lock)
{
var sizes = new List<int>();
while (_queue.First is { } n)
{
if (batch.Count > 0)
{
if (!_allowPacking || n.Value.Exclusive) break;
sizes.Add(n.Value.Request.Length);
if (MultipleServicePacket.BuildSize(sizes) > _channel.MaxRequestBytes) break;
}
else
{
// The first request always goes, even when it alone exceeds the limit: it is sent
// bare, and callers that must fit (LogixTagClient) fragment before they get here.
sizes.Add(n.Value.Request.Length);
}
batch.Add(n.Value);
_queue.RemoveFirst();
if (n.Value.Exclusive) break;
}
}
}
private async Task PumpAsync()
{
while (true)
{
try { await _signal.WaitAsync(_stop.Token).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
// A signal only means "there may be work"; drain the queue fully before waiting again,
// so a surplus signal (e.g. from a cancelled waiter) is just one empty pass.
while (!_stop.IsCancellationRequested)
{
var batch = new List<Pending>();
try
{
TakeBatch(batch);
if (batch.Count == 0) break;
await ExecuteAsync(batch).ConfigureAwait(false);
}
catch (Exception ex)
{
// Last resort: ExecuteAsync already handles every exchange failure, so reaching here
// means the scheduler itself broke (e.g. MaxRequestBytes threw). Fault everything
// pending rather than let the pump die and leave callers hanging, then keep pumping
// so later requests still get an answer.
FaultAll(batch, ex);
break;
}
}
}
}
private void FaultAll(List<Pending> batch, Exception ex)
{
var error = ex as AbTransportException
?? new AbTransportException(AbStatus.BadConnection, $"Request scheduler failed: {ex.Message}", ex);
List<Pending> queued;
lock (_lock)
{
queued = [.. _queue];
_queue.Clear();
}
foreach (var b in batch) b.Fail(error, this);
foreach (var q in queued) q.Fail(error, this);
}
private async Task ExecuteAsync(List<Pending> batch)
{
try
{
if (batch.Count == 1)
{
// Never a waiter's token (B2).
var raw = await _channel.SendAsync(batch[0].Request, _stop.Token).ConfigureAwait(false);
batch[0].Succeed(CipReply.Parse(raw), this);
return;
}
var requests = new List<byte[]>(batch.Count);
foreach (var b in batch) requests.Add(b.Request);
var envelopeRaw = await _channel.SendAsync(MultipleServicePacket.Build(requests), _stop.Token).ConfigureAwait(false);
var envelope = CipReply.Parse(envelopeRaw);
if (envelope.Service != MultipleServiceCode)
throw new AbTransportException(AbStatus.BadReply, $"MSP reply carries service 0x{envelope.Service:X2}.");
if (envelope.GeneralStatus is not (0x00 or EmbeddedServiceError))
throw new AbTransportException(envelope.Status, $"Multiple Service Packet failed: general 0x{envelope.GeneralStatus:X2}.");
var replies = MultipleServicePacket.Parse(envelope, batch.Count);
for (var i = 0; i < batch.Count; i++) batch[i].Succeed(replies[i], this);
}
catch (OperationCanceledException) when (_stop.IsCancellationRequested)
{
// The scheduler is being disposed: not the caller's cancellation, so not an OCE.
var error = DisposedError();
foreach (var b in batch) b.Fail(error, this);
}
catch (AbTransportException ex)
{
foreach (var b in batch) b.Fail(ex, this);
}
catch (Exception ex)
{
var wrapped = new AbTransportException(AbStatus.BadConnection, $"CIP exchange failed: {ex.Message}", ex);
foreach (var b in batch) b.Fail(wrapped, this);
}
}
/// <summary>
/// The error a waiter sees when the scheduler (i.e. its connection) goes away under it. Deliberately
/// NOT an <see cref="OperationCanceledException"/>: upstream treats OCE as "the caller unsubscribed"
/// and swallows it, whereas this must surface as a communication failure. A waiter cancelled by its
/// own token still gets an OCE.
/// </summary>
private static AbTransportException DisposedError() =>
new(AbStatus.BadConnection, "Scheduler disposed.");
/// <summary>Stops the pump. In-flight and queued waiters fault with
/// <see cref="AbTransportException"/> (<see cref="AbStatus.BadConnection"/>).</summary>
public async ValueTask DisposeAsync()
{
lock (_lock)
{
if (_disposed) return;
_disposed = true;
}
_stop.Cancel();
try { await _pump.ConfigureAwait(false); } catch { /* stopping */ }
List<Pending> remaining;
lock (_lock)
{
remaining = [.. _queue];
_queue.Clear();
}
var error = DisposedError();
foreach (var p in remaining) p.Fail(error, this);
_signal.Dispose();
_stop.Dispose();
}
}
@@ -0,0 +1,161 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
/// <summary>
/// Process-wide owner of device connections: exactly one <see cref="CipConnection"/> and one
/// <see cref="RequestScheduler"/> per <see cref="DeviceKey"/> (review B1), handed out as ref-counted
/// <see cref="ConnectionLease"/>s. The last lease's dispose closes the connection; a later acquire builds
/// fresh instances, which do not open until the previous connection has finished closing.
/// </summary>
/// <remarks>Forward Open throttles are kept per key for the process lifetime and never pruned, so a
/// reconnect after the last release is still rate-limited against the previous attempt.</remarks>
public sealed class DeviceSessionPool
{
/// <summary>The process-wide pool the drivers use. Tests build their own.</summary>
public static DeviceSessionPool Shared { get; } = new();
internal sealed class Entry(CipConnection connection, RequestScheduler scheduler, ConnectionOptions options,
Task predecessorClosed)
{
public CipConnection Connection { get; } = connection;
/// <summary>Completes when every earlier connection for this key has finished closing.</summary>
public Task PredecessorClosed { get; } = predecessorClosed;
public RequestScheduler Scheduler { get; } = scheduler;
public ConnectionOptions Options { get; } = options;
public int RefCount; // under the pool lock
public bool MismatchLogged; // under the pool lock
}
private readonly object _lock = new();
private readonly Dictionary<DeviceKey, Entry> _entries = new();
private readonly Dictionary<DeviceKey, ForwardOpenThrottle> _throttles = new();
private readonly Dictionary<DeviceKey, Task> _closing = new();
private readonly TimeProvider _time;
private volatile ILogger _logger;
public DeviceSessionPool(TimeProvider? time = null, ILogger? logger = null)
{
_time = time ?? TimeProvider.System;
_logger = logger ?? NullLogger.Instance;
}
/// <summary>Give <see cref="Shared"/> a logger (it is built before any host exists), so its
/// options-mismatch and close-failure warnings reach the service log. Call once at startup.</summary>
public static void ConfigureSharedLogger(ILoggerFactory loggerFactory)
{
ArgumentNullException.ThrowIfNull(loggerFactory);
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; } }
/// <summary>Lease the shared connection for <paramref name="key"/>, creating it on first use. Performs
/// no I/O: the connection opens on its first request. When the key already has an entry, that entry's
/// options win; a differing <paramref name="options"/> is logged once per entry.</summary>
public ConnectionLease Acquire(DeviceKey key, ConnectionOptions options)
{
ArgumentNullException.ThrowIfNull(key);
ArgumentNullException.ThrowIfNull(options);
lock (_lock)
{
if (!_entries.TryGetValue(key, out var entry))
{
if (!_throttles.TryGetValue(key, out var throttle))
_throttles[key] = throttle = new ForwardOpenThrottle(options.MinForwardOpenInterval, _time);
var predecessor = _closing.GetValueOrDefault(key) ?? Task.CompletedTask;
var connection = new CipConnection(key, options, throttle, _time, predecessor);
entry = new Entry(connection, new RequestScheduler(connection, options.AllowPacking), options, predecessor);
_entries[key] = entry;
}
else if (!entry.Options.Equals(options) && !entry.MismatchLogged)
{
entry.MismatchLogged = true;
_logger.LogWarning(
"Device {Host}:{Port} path '{CipPath}' is already connected with different connection options; " +
"the existing options ({Existing}) stay in force and the requested ({Requested}) are ignored.",
key.Host, key.Port, key.CipPath, entry.Options, options);
}
entry.RefCount++;
return new ConnectionLease(this, key, entry);
}
}
internal async ValueTask ReleaseAsync(DeviceKey key, Entry entry)
{
TaskCompletionSource closed;
Task chain;
lock (_lock)
{
if (--entry.RefCount > 0) return;
_entries.Remove(key);
closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
// The successor must wait for THIS close and for every earlier one still running. An entry
// that never opened closes instantly; without the chain it would hide a slow predecessor
// (A closing, B acquired+released unopened, C opens while A's Forward Close is still pending).
chain = Task.WhenAll(entry.PredecessorClosed, closed.Task);
_closing[key] = chain;
}
_ = chain.ContinueWith(_ =>
{
lock (_lock)
{
if (_closing.TryGetValue(key, out var t) && t == chain) _closing.Remove(key);
}
}, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
// Outside the lock: scheduler first (fails its waiters, stops sending), then Forward Close +
// UnRegisterSession via the connection. The connection is disposed even if the scheduler's
// dispose throws: otherwise its keep-alive would hold the press connection open forever.
try
{
try { await entry.Scheduler.DisposeAsync().ConfigureAwait(false); }
catch (Exception ex)
{
_logger.LogWarning(ex, "Disposing the request scheduler for {Host}:{Port} failed; closing the connection anyway.",
key.Host, key.Port);
}
try { await entry.Connection.DisposeAsync().ConfigureAwait(false); }
catch (Exception ex)
{
_logger.LogWarning(ex, "Closing the connection to {Host}:{Port} failed.", key.Host, key.Port);
}
}
finally
{
closed.TrySetResult(); // never faults: a successor only needs to know the close is over
}
}
}
/// <summary>A reference on a device's shared connection. Dispose exactly once (further disposes are no-ops).</summary>
public sealed class ConnectionLease : IAsyncDisposable
{
private readonly DeviceSessionPool _pool;
private readonly DeviceKey _key;
private readonly DeviceSessionPool.Entry _entry;
private int _disposed;
internal ConnectionLease(DeviceSessionPool pool, DeviceKey key, DeviceSessionPool.Entry entry)
{
_pool = pool;
_key = key;
_entry = entry;
}
public DeviceKey Key => _key;
/// <summary>The device's channel. Prefer <see cref="Scheduler"/>: it serializes and packs requests.</summary>
public ICipChannel Channel => _entry.Connection;
/// <summary>The device's single request scheduler, shared by every lease on the key.</summary>
public RequestScheduler Scheduler => _entry.Scheduler;
public ValueTask DisposeAsync() =>
Interlocked.Exchange(ref _disposed, 1) == 0 ? _pool.ReleaseAsync(_key, _entry) : ValueTask.CompletedTask;
}
@@ -0,0 +1,289 @@
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
/// <summary>
/// One EtherNet/IP TCP session: connects, registers, and sends SendRRData (unconnected) and
/// SendUnitData (connected) requests with <b>at most one request in flight</b>.
/// </summary>
/// <remarks>
/// <para><b>Single flight.</b> Every exchange (write one frame, read until its reply) runs under a
/// <see cref="SemaphoreSlim"/>(1,1). The connected sequence count is advanced inside that gate, so
/// concurrent callers can never put sequences on the wire out of order.</para>
/// <para><b>Caller cancellation never faults the connection, and never misattributes a reply</b>
/// (rulings R5-1/R5-2). The caller gets <see cref="OperationCanceledException"/> at once, but the
/// abandoned exchange keeps reading <i>its own</i> reply in the background while still holding the
/// gate, bounded by the original timeout. The reply is discarded and the connection stays healthy;
/// if the timeout elapses first the connection faults exactly as a normal timeout does. Because the
/// next request is not written until the previous reply has been consumed, a late reply can never be
/// taken for the next request's — which matters because ab_server zeroes the reply sender context,
/// so context matching alone cannot tell two unconnected replies apart.</para>
/// <para><b>Faults.</b> A timeout, an I/O error or an encapsulation-level protocol error
/// (<see cref="AbStatus.BadReply"/>) closes the socket; <see cref="IsConnected"/> turns false and
/// every later call throws <see cref="AbTransportException"/>(<see cref="AbStatus.BadConnection"/>).
/// The owner reconnects with a fresh client.</para>
/// <para><b>No half-written frames.</b> A frame write never observes a cancellation token.</para>
/// </remarks>
public sealed class EncapsulationClient : IAsyncDisposable
{
private static readonly TimeSpan UnregisterBudget = TimeSpan.FromMilliseconds(500);
private readonly TcpClient _tcp;
private readonly NetworkStream _stream;
// Never disposed: an abandoned exchange may release it after DisposeAsync returns, and
// SemaphoreSlim.Release on a disposed instance throws. It holds no unmanaged resource unless
// AvailableWaitHandle is touched, which this class never does.
private readonly SemaphoreSlim _gate = new(1, 1);
private long _ctx;
private ushort _seq; // connected sequence count; only touched under _gate
private volatile bool _faulted;
private int _disposed;
/// <summary>The session handle the target assigned in its RegisterSession reply.</summary>
public uint SessionHandle { get; private set; }
/// <summary>False once the connection has faulted or been disposed.</summary>
public bool IsConnected => !_faulted && Volatile.Read(ref _disposed) == 0 && _tcp.Connected;
private EncapsulationClient(TcpClient tcp)
{
_tcp = tcp;
_stream = tcp.GetStream();
}
/// <summary>Open the TCP connection and register an encapsulation session. <paramref name="timeout"/>
/// bounds the TCP connect and the RegisterSession exchange separately.</summary>
public static async Task<EncapsulationClient> ConnectAsync(string host, int port, TimeSpan timeout, CancellationToken ct)
{
var tcp = new TcpClient { NoDelay = true };
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeout);
await tcp.ConnectAsync(host, port, cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
tcp.Dispose();
throw new AbTransportException(AbStatus.Timeout, $"TCP connect to {host}:{port} timed out.");
}
catch (SocketException ex)
{
tcp.Dispose();
throw new AbTransportException(AbStatus.BadConnection, $"TCP connect to {host}:{port} failed: {ex.SocketErrorCode}", ex);
}
catch
{
tcp.Dispose();
throw;
}
var client = new EncapsulationClient(tcp);
try
{
var (header, _) = await client.ExchangeAsync(connected: false,
(_, _) => EncapsulationCodec.RegisterSession(), timeout, ct).ConfigureAwait(false);
client.SessionHandle = header.SessionHandle;
return client;
}
catch
{
client.Abort();
throw;
}
}
/// <summary>Send one unconnected (UCMM) CIP request and return the CIP reply bytes.</summary>
public async Task<ReadOnlyMemory<byte>> SendRRDataAsync(ReadOnlyMemory<byte> cip, TimeSpan timeout, CancellationToken ct)
{
var secs = (ushort)Math.Clamp((int)Math.Ceiling(timeout.TotalSeconds), 1, ushort.MaxValue);
var (_, reply) = await ExchangeAsync(connected: false,
(ctx, _) => EncapsulationCodec.SendRRData(SessionHandle, ctx, cip.Span, secs), timeout, ct).ConfigureAwait(false);
return reply;
}
/// <summary>Send one connected (class 3) CIP request over <paramref name="otConnectionId"/> and return
/// the CIP reply bytes. The client owns the connected sequence count (review B1).</summary>
public async Task<ReadOnlyMemory<byte>> SendUnitDataAsync(uint otConnectionId, ReadOnlyMemory<byte> cip,
TimeSpan timeout, CancellationToken ct)
{
var (_, reply) = await ExchangeAsync(connected: true,
(ctx, seq) => EncapsulationCodec.SendUnitData(SessionHandle, ctx, otConnectionId, seq, cip.Span), timeout, ct)
.ConfigureAwait(false);
return reply;
}
/// <summary>The connected sequence count after <paramref name="current"/>: increments, wrapping
/// 65535 → 1 (0 is never put on the wire).</summary>
internal static ushort NextSequence(ushort current) => current == ushort.MaxValue ? (ushort)1 : (ushort)(current + 1);
/// <summary>Acquire the gate, build the request (sequence assigned under the gate), then hand the
/// gate to <see cref="RunExchangeAsync"/>, which owns its release from that point on.</summary>
private async Task<(EncapsulationHeader Header, ReadOnlyMemory<byte> Cip)> ExchangeAsync(
bool connected, Func<ulong, ushort, byte[]> build, TimeSpan timeout, CancellationToken ct)
{
ThrowIfUnusable();
await _gate.WaitAsync(ct).ConfigureAwait(false);
Task<(EncapsulationHeader, ReadOnlyMemory<byte>)> exchange;
try
{
ThrowIfUnusable(); // a drain we queued behind may have faulted
ct.ThrowIfCancellationRequested(); // nothing written yet: cancel cleanly
var ctx = (ulong)Interlocked.Increment(ref _ctx);
ushort seq = 0;
if (connected) seq = _seq = NextSequence(_seq);
var request = build(ctx, seq);
exchange = RunExchangeAsync(request, connected, ctx, seq, timeout);
}
catch
{
_gate.Release(); // RunExchangeAsync never started: release here
throw;
}
try
{
return await exchange.WaitAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Caller abandoned the wait (R5-1). The exchange keeps draining its own reply under the
// gate and faults on its own timeout; observe its outcome so a fault is never unobserved.
_ = exchange.ContinueWith(static t => _ = t.Exception, CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
throw;
}
}
/// <summary>Write one frame, then read frames until the matching reply arrives. Releases the gate
/// exactly once, in its <c>finally</c>. Never throws <see cref="OperationCanceledException"/>: every
/// failure surfaces as <see cref="AbTransportException"/> and faults the connection first.</summary>
private async Task<(EncapsulationHeader, ReadOnlyMemory<byte>)> RunExchangeAsync(
byte[] request, bool connected, ulong ctx, ushort seq, TimeSpan timeout)
{
var cmd = (ushort)(request[0] | (request[1] << 8));
try
{
using var timeoutCts = new CancellationTokenSource(timeout);
try
{
await _stream.WriteAsync(request, CancellationToken.None).ConfigureAwait(false);
while (true)
{
var frame = await ReadFrameAsync(timeoutCts.Token).ConfigureAwait(false);
if (TryMatch(frame, cmd, connected, ctx, seq, out var reply)) return reply;
}
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
Fault();
throw new AbTransportException(AbStatus.Timeout,
$"No reply to command 0x{cmd:X4} within {timeout.TotalMilliseconds:F0} ms.");
}
catch (AbTransportException)
{
Fault();
throw;
}
catch (Exception ex) when (ex is IOException or SocketException or ObjectDisposedException
or EndOfStreamException or InvalidOperationException)
{
Fault();
throw new AbTransportException(AbStatus.BadConnection, $"Connection lost: {ex.Message}", ex);
}
}
finally
{
_gate.Release();
}
}
/// <summary>Does <paramref name="frame"/> answer the request in flight? Unconnected replies match on
/// command and sender context, tolerating a zeroed context (ab_server, R5-2); connected replies match
/// on the echoed sequence count. Anything else is skipped. A matching frame is fully validated, so an
/// encapsulation error throws BadReply.</summary>
private static bool TryMatch(byte[] frame, ushort cmd, bool connected, ulong ctx, ushort seq,
out (EncapsulationHeader, ReadOnlyMemory<byte>) reply)
{
reply = default;
var h = EncapsulationCodec.ReadHeader(frame);
if (h.Command != cmd) return false;
if (!connected)
{
if (h.SenderContext != ctx && h.SenderContext != 0) return false;
var p = EncapsulationCodec.ParseReply(frame, expectConnected: false);
reply = (p.Header, p.Cip);
return true;
}
// A connected error reply (status != 0) carries no readable sequence; with one request in flight
// and abandoned exchanges drained, it can only be ours — ParseReply throws BadReply for it.
var c = EncapsulationCodec.ParseReply(frame, expectConnected: true);
if (c.Sequence != seq) return false;
reply = (c.Header, c.Cip);
return true;
}
private async Task<byte[]> ReadFrameAsync(CancellationToken ct)
{
var head = new byte[EncapsulationHeader.Size];
await _stream.ReadExactlyAsync(head, ct).ConfigureAwait(false);
var len = head[2] | (head[3] << 8);
var frame = new byte[EncapsulationHeader.Size + len];
head.CopyTo(frame, 0);
await _stream.ReadExactlyAsync(frame.AsMemory(EncapsulationHeader.Size), ct).ConfigureAwait(false);
return frame;
}
private void ThrowIfUnusable()
{
if (Volatile.Read(ref _disposed) != 0)
throw new AbTransportException(AbStatus.BadConnection, "Session is disposed.");
if (_faulted)
throw new AbTransportException(AbStatus.BadConnection, "Session is faulted; reconnect.");
}
private void Fault()
{
_faulted = true;
try { _tcp.Close(); } catch { /* already closed */ }
}
/// <summary>Close without UnRegisterSession (registration never completed).</summary>
private void Abort()
{
Interlocked.Exchange(ref _disposed, 1);
Fault();
}
/// <summary>Best-effort UnRegisterSession, then close. Waits briefly for an in-flight exchange so the
/// UnRegisterSession frame is never interleaved with another frame; if the gate is not free in time
/// the socket is closed without it.</summary>
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
var acquired = await _gate.WaitAsync(UnregisterBudget).ConfigureAwait(false);
try
{
if (acquired && !_faulted && _tcp.Connected)
{
try
{
var bytes = EncapsulationCodec.UnRegisterSession(SessionHandle, (ulong)Interlocked.Increment(ref _ctx));
using var cts = new CancellationTokenSource(UnregisterBudget);
await _stream.WriteAsync(bytes, cts.Token).ConfigureAwait(false);
await _stream.FlushAsync(cts.Token).ConfigureAwait(false);
}
catch { /* best effort: the socket is closed next either way */ }
}
}
finally
{
Fault();
_tcp.Dispose();
if (acquired) _gate.Release();
}
}
}
@@ -0,0 +1,115 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
/// <summary>Builds and parses EtherNet/IP encapsulation frames, including the Common Packet Format
/// (CPF) items SendRRData / SendUnitData carry.</summary>
public static class EncapsulationCodec
{
/// <summary>CPF item: Null Address (unconnected messaging).</summary>
public const ushort ItemNullAddress = 0x0000;
/// <summary>CPF item: Connected Address (carries the O→T connection id).</summary>
public const ushort ItemConnectedAddress = 0x00A1;
/// <summary>CPF item: Connected Data (sequence count + CIP).</summary>
public const ushort ItemConnectedData = 0x00B1;
/// <summary>CPF item: Unconnected Data (CIP).</summary>
public const ushort ItemUnconnectedData = 0x00B2;
/// <summary>Largest CIP payload one frame can carry: the encapsulation length field (u16) also
/// covers the CPF overhead, so anything near 64 KiB cannot be framed.</summary>
public const int MaxCipPayload = ushort.MaxValue - 64;
private static CipWriter Header(ushort cmd, uint session, ulong ctx) =>
new CipWriter().U16(cmd).U16(0).U32(session).U32(0).U64(ctx).U32(0);
private static byte[] Finish(CipWriter w)
{
w.PatchU16(2, (ushort)(w.Length - EncapsulationHeader.Size));
return w.ToArray();
}
/// <summary>RegisterSession: protocol version 1, option flags 0, and a <b>zero</b> sender context.</summary>
/// <remarks>The sender context is always zero, as libplctag sends it (<c>logix-register-session.hex</c>):
/// ab_server's RegisterSession sanity check rejects any non-zero context with encapsulation status 1
/// and a header-only reply, so a per-request context here failed every native session against the
/// simulator. The reply is still matched — unconnected matching tolerates a zero context.</remarks>
public static byte[] RegisterSession() =>
Finish(Header(EncapCommand.RegisterSession, 0, 0).U16(1).U16(0));
/// <summary>UnRegisterSession: header only, no data.</summary>
public static byte[] UnRegisterSession(uint session, ulong ctx) =>
Finish(Header(EncapCommand.UnRegisterSession, session, ctx));
/// <summary>SendRRData (unconnected): Null Address item + Unconnected Data item carrying <paramref name="cip"/>.</summary>
public static byte[] SendRRData(uint session, ulong ctx, ReadOnlySpan<byte> cip, ushort timeoutSeconds) =>
Finish(Header(EncapCommand.SendRRData, session, ctx)
.U32(0).U16(timeoutSeconds).U16(2)
.U16(ItemNullAddress).U16(0)
.U16(ItemUnconnectedData).U16(CheckedLength(cip.Length)).Bytes(cip));
/// <summary>SendUnitData (connected): Connected Address item + Connected Data item carrying the
/// sequence count followed by <paramref name="cip"/>.</summary>
public static byte[] SendUnitData(uint session, ulong ctx, uint connectionId, ushort sequence, ReadOnlySpan<byte> cip) =>
Finish(Header(EncapCommand.SendUnitData, session, ctx)
.U32(0).U16(0).U16(2)
.U16(ItemConnectedAddress).U16(4).U32(connectionId)
.U16(ItemConnectedData).U16((ushort)(CheckedLength(cip.Length) + 2)).U16(sequence).Bytes(cip));
private static ushort CheckedLength(int length)
{
if (length > MaxCipPayload)
throw new AbTransportException(AbStatus.TooLarge, $"CIP payload of {length} bytes cannot be framed (max {MaxCipPayload}).");
return (ushort)length;
}
/// <summary>Read the 24-byte header at the start of <paramref name="b"/>.</summary>
public static EncapsulationHeader ReadHeader(ReadOnlySpan<byte> b)
{
var r = new CipReader(b);
return new EncapsulationHeader(r.U16(), r.U16(), r.U32(), r.U32(), r.U64(), r.U32());
}
/// <summary>Parse a reply frame. Returns the header plus the CIP payload of the data item
/// (0x00B2 unconnected / 0x00B1 connected, sequence count stripped) and, for a connected reply,
/// the echoed sequence count. RegisterSession / UnRegisterSession replies return an empty payload.
/// Throws <see cref="AbTransportException"/>(<see cref="AbStatus.BadReply"/>) on a non-zero
/// encapsulation status, a length mismatch, a truncated item list or a missing data item.</summary>
public static (EncapsulationHeader Header, ReadOnlyMemory<byte> Cip, ushort Sequence) ParseReply(
ReadOnlyMemory<byte> frame, bool expectConnected)
{
var h = ReadHeader(frame.Span);
if (h.Status != 0)
throw new AbTransportException(AbStatus.BadReply, $"Encapsulation status 0x{h.Status:X8} on command 0x{h.Command:X4}.");
if (h.Length != frame.Length - EncapsulationHeader.Size)
throw new AbTransportException(AbStatus.BadReply,
$"Encapsulation length mismatch: header says {h.Length}, frame carries {frame.Length - EncapsulationHeader.Size}.");
if (h.Command is EncapCommand.RegisterSession or EncapCommand.UnRegisterSession)
return (h, ReadOnlyMemory<byte>.Empty, 0);
var r = new CipReader(frame.Span[EncapsulationHeader.Size..]);
r.U32(); r.U16(); // interface handle, timeout
var items = r.U16();
var offset = EncapsulationHeader.Size + 8;
var wanted = expectConnected ? ItemConnectedData : ItemUnconnectedData;
for (var i = 0; i < items; i++)
{
var type = r.U16();
var len = r.U16();
offset += 4;
r.Bytes(len); // bounds-checks the item against the frame
if (type == wanted)
{
if (!expectConnected) return (h, frame.Slice(offset, len), 0);
if (len < 2)
throw new AbTransportException(AbStatus.BadReply, "Connected data item shorter than its sequence count.");
var seq = (ushort)(frame.Span[offset] | (frame.Span[offset + 1] << 8));
return (h, frame.Slice(offset + 2, len - 2), seq);
}
offset += len;
}
throw new AbTransportException(AbStatus.BadReply,
$"Reply to command 0x{h.Command:X4} carried no 0x{wanted:X4} data item.");
}
}
@@ -0,0 +1,15 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
/// <summary>EtherNet/IP encapsulation command codes (CIP Vol. 2, Table 2-3.2).</summary>
public static class EncapCommand
{
public const ushort ListIdentity = 0x0063, RegisterSession = 0x0065, UnRegisterSession = 0x0066,
SendRRData = 0x006F, SendUnitData = 0x0070;
}
/// <summary>The fixed 24-byte EtherNet/IP encapsulation header (all fields little-endian).</summary>
public readonly record struct EncapsulationHeader(
ushort Command, ushort Length, uint SessionHandle, uint Status, ulong SenderContext, uint Options)
{
public const int Size = 24;
}
@@ -0,0 +1,39 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
/// <summary>Logix Read/Write Tag (plain and fragmented) builders and the read-reply parser.</summary>
public static class LogixServices
{
private static CipWriter Start(byte service, ReadOnlySpan<byte> path) =>
new CipWriter().U8(service).U8((byte)(path.Length / 2)).Bytes(path);
public static byte[] ReadTag(ReadOnlySpan<byte> path, ushort elements) => Start(0x4C, path).U16(elements).ToArray();
public static byte[] ReadTagFragmented(ReadOnlySpan<byte> path, ushort elements, uint offset) =>
Start(0x52, path).U16(elements).U32(offset).ToArray();
public static byte[] WriteTag(ReadOnlySpan<byte> path, ushort type, ushort? handle, ushort elements, ReadOnlySpan<byte> data)
{
var w = Start(0x4D, path).U16(type);
if (type == LogixTypeCode.Struct) w.U16(handle ?? throw new ArgumentException("Struct write needs a handle."));
return w.U16(elements).Bytes(data).ToArray();
}
public static byte[] WriteTagFragmented(ReadOnlySpan<byte> path, ushort type, ushort? handle, ushort elements, uint offset, ReadOnlySpan<byte> data)
{
var w = Start(0x53, path).U16(type);
if (type == LogixTypeCode.Struct) w.U16(handle ?? throw new ArgumentException("Struct write needs a handle."));
return w.U16(elements).U32(offset).Bytes(data).ToArray();
}
public static (ushort TypeCode, ushort? StructHandle, ReadOnlyMemory<byte> Data) ParseReadReply(CipReply reply)
{
if (reply.GeneralStatus is not (0x00 or 0x06))
throw new AbTransportException(reply.Status, $"Read failed: general 0x{reply.GeneralStatus:X2}.");
var r = new CipReader(reply.Data.Span);
var type = r.U16();
ushort? handle = type == LogixTypeCode.Struct ? r.U16() : null;
return (type, handle, reply.Data[r.Position..]);
}
}
@@ -0,0 +1,136 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
/// <summary>
/// Logix tag read/write over a connection's <see cref="RequestScheduler"/>, including the fragment
/// loops for values that do not fit in one frame.
/// </summary>
/// <remarks>
/// Reads are issued as Read Tag Fragmented (0x52) starting at offset 0 — exactly what libplctag puts
/// on the wire (golden captures, Task 4) — so the native and libplctag frames stay byte-comparable,
/// and the continuation requests are the same service at a non-zero offset.
/// </remarks>
public sealed class LogixTagClient(RequestScheduler scheduler, ICipChannel channel)
{
private const byte StatusSuccess = 0x00;
private const byte StatusPartial = 0x06;
/// <summary>Fixed bytes of a Write Tag Fragmented request besides path and data: service, path
/// size, type, struct handle, element count, offset.</summary>
private const int FragmentedWriteOverhead = 12;
/// <summary>Upper bound on one read's accumulated data, so a controller that keeps answering
/// Partial (0x06) cannot grow memory without limit. Far above any real Logix tag.</summary>
internal const int MaxReadBytes = 2 * 1024 * 1024;
/// <summary>Chunk alignment when one element is wider than a frame (Logix lays structures out on
/// at most 8-byte boundaries).</summary>
private const int WideElementAlignment = 8;
private readonly RequestScheduler _scheduler = scheduler ?? throw new ArgumentNullException(nameof(scheduler));
private readonly ICipChannel _channel = channel ?? throw new ArgumentNullException(nameof(channel));
/// <summary>
/// Read <paramref name="elements"/> elements of the tag at the encoded <paramref name="path"/>.
/// A Partial (0x06) reply keeps its data and the read continues at <c>offset = bytes received so
/// far</c> until a reply reports success. The type code and struct handle come from the first
/// reply; each fragment reply's repeated type header is stripped before its data is appended.
/// </summary>
/// <remarks>Packing bounds only the request size, so a packed batch of large reads can come back
/// with 0x06 per service; that is expected, and this loop recovers it.</remarks>
public async Task<(ushort TypeCode, ushort? StructHandle, byte[] Data)> ReadAsync(
byte[] path, ushort elements, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(path);
var reply = await _scheduler.SendAsync(LogixServices.ReadTagFragmented(path, elements, 0), ct).ConfigureAwait(false);
var (type, handle, first) = LogixServices.ParseReadReply(reply); // throws on any status but 0x00/0x06
var data = new List<byte>(first.Length);
data.AddRange(first.Span);
var lastChunk = first.Length;
while (reply.GeneralStatus == StatusPartial)
{
if (lastChunk == 0)
throw new AbTransportException(AbStatus.BadReply, "Partial read reply carried no data; the fragment loop cannot progress.");
reply = await _scheduler.SendAsync(
LogixServices.ReadTagFragmented(path, elements, (uint)data.Count), ct).ConfigureAwait(false);
var (fragmentType, _, chunk) = LogixServices.ParseReadReply(reply);
if (fragmentType != type)
throw new AbTransportException(AbStatus.BadReply,
$"Read fragment type 0x{fragmentType:X4} differs from the first reply's 0x{type:X4}.");
data.AddRange(chunk.Span);
lastChunk = chunk.Length;
if (data.Count > MaxReadBytes)
throw new AbTransportException(AbStatus.TooLarge,
$"Fragmented read exceeded {MaxReadBytes} bytes; the controller kept answering Partial.");
}
return (type, handle, data.ToArray());
}
/// <summary>
/// Write <paramref name="data"/> to the tag at <paramref name="path"/>. One Write Tag when the
/// request fits <see cref="ICipChannel.MaxRequestBytes"/>; otherwise Write Tag Fragmented chunks of
/// <c>MaxRequestBytes - 12 - path.Length</c> bytes aligned down to the element width (or to 8 bytes
/// when a single element is wider than a frame).
/// </summary>
/// <remarks>A fragmented write is NOT atomic across fragments — the controller applies each
/// fragment as it arrives, so a reader can observe a partly-written value. libplctag behaves the
/// same way.</remarks>
/// <exception cref="ArgumentException">For an atomic type, <paramref name="data"/> is not exactly
/// <paramref name="elements"/> × the type's width.</exception>
public async Task WriteAsync(byte[] path, ushort type, ushort? handle, ushort elements,
ReadOnlyMemory<byte> data, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(path);
var atomicWidth = LogixTypeCode.ByteWidth(type);
if (atomicWidth > 0 && data.Length != elements * atomicWidth)
throw new ArgumentException(
$"Write of {elements} element(s) of type 0x{type:X4} needs {elements * atomicWidth} bytes, got {data.Length}.",
nameof(data));
var max = _channel.MaxRequestBytes;
var isStruct = type == LogixTypeCode.Struct;
var singleSize = 2 + path.Length + 2 + (isStruct ? 2 : 0) + 2 + data.Length;
if (singleSize <= max)
{
var reply = await _scheduler.SendAsync(LogixServices.WriteTag(path, type, handle, elements, data.Span), ct).ConfigureAwait(false);
EnsureSuccess(reply);
return;
}
var chunkSize = AlignedChunkSize(max - FragmentedWriteOverhead - path.Length, ElementWidth(type, elements, data.Length));
for (var offset = 0; offset < data.Length; offset += chunkSize)
{
var length = Math.Min(chunkSize, data.Length - offset);
var request = LogixServices.WriteTagFragmented(path, type, handle, elements, (uint)offset, data.Span.Slice(offset, length));
var reply = await _scheduler.SendAsync(request, ct).ConfigureAwait(false);
EnsureSuccess(reply);
}
}
private static int ElementWidth(ushort type, ushort elements, int dataLength)
{
var width = LogixTypeCode.ByteWidth(type);
if (width > 0) return width;
return elements > 0 ? Math.Max(1, dataLength / elements) : 1;
}
private static int AlignedChunkSize(int room, int width)
{
// A structure element can be wider than one frame. Write Tag Fragmented's offset is a byte
// offset and the controller reassembles, so this is allowed: align to 8 bytes instead of
// refusing the write.
var alignment = width <= room ? width : WideElementAlignment;
var chunk = room / alignment * alignment;
if (chunk <= 0)
throw new AbTransportException(AbStatus.TooLarge,
$"No room for write data: MaxRequestBytes leaves {room} byte(s) after the path.");
return chunk;
}
private static void EnsureSuccess(CipReply reply)
{
if (reply.GeneralStatus != StatusSuccess)
throw new AbTransportException(reply.Status, $"Write failed: general 0x{reply.GeneralStatus:X2}.");
}
}
@@ -0,0 +1,17 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
public static class LogixTypeCode
{
public const ushort Bool = 0xC1, SInt = 0xC2, Int = 0xC3, DInt = 0xC4, LInt = 0xC5, USInt = 0xC6,
UInt = 0xC7, UDInt = 0xC8, ULInt = 0xC9, Real = 0xCA, LReal = 0xCB, BoolArray = 0xD3, Struct = 0x02A0;
/// <summary>Byte width of an atomic type, or -1 when the code is not atomic (e.g. <see cref="Struct"/>).</summary>
public static int ByteWidth(ushort code) => code switch
{
Bool or SInt or USInt => 1,
Int or UInt => 2,
DInt or UDInt or Real or BoolArray => 4,
LInt or ULInt or LReal => 8,
_ => -1,
};
}
@@ -0,0 +1,50 @@
using System.Text;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
/// <summary>Symbol Object (class 0x6B) instance listing — the <c>@tags</c> enumeration.</summary>
public static class SymbolObjectServices
{
/// <summary>Request attributes: 2 = type, 7 = element length, 8 = array dims, 1 = name. Identical
/// to what libplctag sends (<c>logix-tags-listing.hex</c>).</summary>
public static byte[] GetInstanceAttributeList(string? programName, uint startInstance)
{
var path = new CipWriter();
if (!string.IsNullOrEmpty(programName))
{
var a = Encoding.ASCII.GetBytes(programName.StartsWith("Program:", StringComparison.Ordinal) ? programName : "Program:" + programName);
path.U8(0x91).U8((byte)a.Length).Bytes(a).PadToEven();
}
path.U8(0x20).U8(0x6B);
// 16-bit instance segment even for small values (libplctag's form, see capture); 32-bit above 0xFFFF.
if (startInstance <= 0xFFFF) path.U8(0x25).U8(0).U16((ushort)startInstance);
else path.U8(0x26).U8(0).U32(startInstance);
var p = path.ToArray();
return new CipWriter().U8(0x55).U8((byte)(p.Length / 2)).Bytes(p)
.U16(4).U16(2).U16(7).U16(8).U16(1).ToArray();
}
/// <summary>Entries are <c>u32 instance, u16 type, u16 elemLen, u32×3 dims, u16 nameLen, name</c>.
/// <c>LastInstance</c> is the last entry's instance id (next start = LastInstance + 1).
/// <c>More</c> is true on general status 0x06 (partial transfer).</summary>
public static (byte[] Entries, uint LastInstance, bool More) ParseListingReply(CipReply reply)
{
if (reply.GeneralStatus is not (0x00 or 0x06))
throw new AbTransportException(reply.Status, $"Symbol listing failed: general 0x{reply.GeneralStatus:X2}.");
var span = reply.Data.Span;
var r = new CipReader(span);
uint last = 0;
while (r.Remaining > 0)
{
last = r.U32();
r.Bytes(2 + 2 + 12);
var nameLen = r.U16();
r.Bytes(nameLen);
}
var more = reply.GeneralStatus == 0x06;
if (more && span.IsEmpty)
throw new AbTransportException(AbStatus.BadReply, "Symbol listing reported more data but returned no entries.");
return (span.ToArray(), last, more);
}
}
@@ -0,0 +1,52 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
public sealed record TemplateAttributes(uint DefinitionWords, uint StructureBytes, ushort Members, ushort Handle);
/// <summary>Template Object (class 0x6C) attribute query and definition read.</summary>
public static class TemplateObjectServices
{
/// <summary>Get Attribute List for attributes 4 (definition words), 5 (structure bytes),
/// 2 (member count) and 1 (handle).</summary>
public static byte[] GetAttributes(ushort templateId) =>
new CipWriter().U8(0x03).U8(3).Bytes([0x20, 0x6C, 0x25, 0x00]).U16(templateId)
.U16(4).U16(4).U16(5).U16(2).U16(1).ToArray();
public static TemplateAttributes ParseAttributes(CipReply reply)
{
if (reply.GeneralStatus != 0)
throw new AbTransportException(reply.Status, $"Template attributes failed: general 0x{reply.GeneralStatus:X2}.");
var r = new CipReader(reply.Data.Span);
var count = r.U16();
uint defWords = 0, structBytes = 0; ushort members = 0, handle = 0;
for (var i = 0; i < count; i++)
{
var id = r.U16(); var status = r.U16();
if (status != 0)
throw new AbTransportException(AbStatus.BadReply, $"Template attribute {id} status 0x{status:X4}.");
switch (id)
{
case 4: defWords = r.U32(); break;
case 5: structBytes = r.U32(); break;
case 2: members = r.U16(); break;
case 1: handle = r.U16(); break;
default: throw new AbTransportException(AbStatus.BadReply, $"Unexpected template attribute {id}.");
}
}
return new TemplateAttributes(defWords, structBytes, members, handle);
}
/// <summary>Read Template: <c>4C 03 20 6C 25 00 id offset bytes</c>.</summary>
public static byte[] ReadTemplate(ushort templateId, uint offset, ushort bytes) =>
new CipWriter().U8(0x4C).U8(3).Bytes([0x20, 0x6C, 0x25, 0x00]).U16(templateId).U32(offset).U16(bytes).ToArray();
/// <summary>More ⇔ general status 0x06.</summary>
public static bool ReadHasMore(CipReply reply) => reply.GeneralStatus == 0x06;
/// <summary>The buffer <c>CipTemplateObjectDecoder</c> expects: <c>u16 id, u32 defWords,
/// u32 structBytes, u16 members, u16 handle</c> then the raw template bytes.</summary>
public static byte[] BuildDecoderBuffer(ushort templateId, TemplateAttributes attrs, ReadOnlySpan<byte> rawTemplate) =>
new CipWriter().U16(templateId).U32(attrs.DefinitionWords).U32(attrs.StructureBytes)
.U16(attrs.Members).U16(attrs.Handle).Bytes(rawTemplate).ToArray();
}
@@ -0,0 +1,108 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
/// <summary>Transport-side PCCC data-table address. Independent of the driver's AbLegacyAddress.</summary>
/// <param name="FileLetter">File letter(s): N, F, B, T, C, R, ST, L, A, I, O, S.</param>
/// <param name="FileNumber">Explicit file number; null for O/I/S (implicit 0/1/2).</param>
/// <param name="Element">Element (word) number.</param>
/// <param name="SubElement">Word offset within a structured element (T/C: PRE=1, ACC=2; R: LEN=1, POS=2); null = 0.</param>
/// <param name="Bit">Bit within the addressed word (<c>/n</c> or a status-bit sub-element), else null.</param>
public sealed record PcccAddress(string FileLetter, int? FileNumber, int Element, int? SubElement, int? Bit = null)
{
/// <summary>The PCCC file-type byte; throws Unsupported for an unknown letter.</summary>
public byte FileTypeCode =>
PcccFileType.CodeFor(FileLetter)
?? throw new AbTransportException(AbStatus.Unsupported, $"Unknown PCCC file type '{FileLetter}'.");
/// <summary>Explicit file number, else the implicit one for O/I/S.</summary>
public int EffectiveFileNumber =>
FileNumber ?? PcccFileType.ImplicitFileNumber(FileLetter)
?? throw new AbTransportException(AbStatus.Unsupported, $"File type '{FileLetter}' needs an explicit file number.");
/// <summary>The word offset to read within the element, and the bit within that word if any.</summary>
public (int Word, int? Bit) ResolveSubElement() => (SubElement ?? 0, Bit);
/// <summary>Builds an address from a sub-element mnemonic (PRE, ACC, LEN, POS, or a status bit such as DN).</summary>
public static PcccAddress WithSubElementName(string letter, int? fileNumber, int element, string subName)
{
var l = letter.ToUpperInvariant();
var s = subName.ToUpperInvariant();
(int Word, int? Bit)? r = (l, s) switch
{
("T" or "C", "PRE") => (1, null),
("T" or "C", "ACC") => (2, null),
("R", "LEN") => (1, null),
("R", "POS") => (2, null),
("T", "EN") => (0, 15),
("T", "TT") => (0, 14),
("T", "DN") => (0, 13),
("C", "CU") => (0, 15),
("C", "CD") => (0, 14),
("C", "DN") => (0, 13),
("C", "OV") => (0, 12),
("C", "UN") => (0, 11),
("C", "UA") => (0, 10),
("R", "EN") => (0, 15),
("R", "EU") => (0, 14),
("R", "DN") => (0, 13),
("R", "EM") => (0, 12),
("R", "ER") => (0, 11),
("R", "UL") => (0, 10),
("R", "IN") => (0, 9),
("R", "FD") => (0, 8),
_ => null,
};
if (r is not { } v)
throw new AbTransportException(AbStatus.Unsupported, $"Unknown sub-element '{subName}' for file type '{letter}'.");
return new PcccAddress(l, fileNumber, element, v.Word, v.Bit);
}
/// <summary>Parses <c>N7:0</c>, <c>B3:1/4</c>, <c>T4:0.ACC</c>, <c>I:0/1</c>, <c>ST9:2</c>; null when malformed.</summary>
public static PcccAddress? TryParse(string? text)
{
if (string.IsNullOrWhiteSpace(text)) return null;
var src = text.Trim();
int? bit = null;
var slash = src.IndexOf('/');
if (slash >= 0)
{
if (!int.TryParse(src[(slash + 1)..], out var b) || b < 0) return null;
bit = b;
src = src[..slash];
}
string? sub = null;
var dot = src.LastIndexOf('.');
if (dot >= 0)
{
sub = src[(dot + 1)..];
if (sub.Length == 0 || !sub.All(char.IsLetter)) return null;
src = src[..dot];
}
var colon = src.IndexOf(':');
if (colon <= 0 || !int.TryParse(src[(colon + 1)..], out var element) || element < 0) return null;
var filePart = src[..colon];
if (!char.IsLetter(filePart[0])) return null;
var end = 1;
while (end < filePart.Length && char.IsLetter(filePart[end])) end++;
var letter = filePart[..end].ToUpperInvariant();
if (PcccFileType.CodeFor(letter) is null) return null;
int? file = null;
if (end < filePart.Length)
{
if (!int.TryParse(filePart[end..], out var fn) || fn < 0) return null;
file = fn;
}
var implicitFile = PcccFileType.ImplicitFileNumber(letter);
if (implicitFile is not null && file is not null) return null;
if (implicitFile is null && file is null) return null;
if (sub is not null && bit is not null) return null;
if (bit is int bb)
{
// Same limits as AbLegacyAddress: 16-bit words N/B/I/O/S/A 0-15, L 0-31; no bits on F/T/C/R/ST.
var max = letter switch { "L" => 31, "N" or "B" or "I" or "O" or "S" or "A" => 15, _ => -1 };
if (bb > max) return null;
}
if (sub is null) return new PcccAddress(letter, file, element, null, bit);
try { return WithSubElementName(letter, file, element, sub); }
catch (AbTransportException) { return null; }
}
}
@@ -0,0 +1,39 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
/// <summary>PCCC file-type codes for the protected typed logical read/write commands (0xA2/0xAA).
/// <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>
public static byte? CodeFor(string fileLetter) => fileLetter.ToUpperInvariant() switch
{
"O" => 0x8B,
"I" => 0x8C,
"S" => 0x84,
"B" => 0x85,
"T" => 0x86,
"C" => 0x87,
"R" => 0x88,
"N" => 0x89,
"F" => 0x8A,
"ST" => 0x8D,
"A" => 0x8E,
"L" => 0x91,
_ => null,
};
/// <summary>The implicit file number for the single-letter system files (O=0, I=1, S=2), else null.</summary>
public static int? ImplicitFileNumber(string fileLetter) => fileLetter.ToUpperInvariant() switch
{
"O" => 0,
"I" => 1,
"S" => 2,
_ => null,
};
}
@@ -0,0 +1,120 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
/// <summary>Pure builders/parsers for Execute PCCC (CIP 0x4B on class 0x67) carrying SLC/MicroLogix
/// protected typed logical commands and PLC-5 range reads. Framing is taken from the golden captures.
/// The Execute PCCC request is sent bare (no Unconnected Send, no route) per the captures.</summary>
public static class PcccServices
{
/// <summary>Largest data byte count one typed read/write may carry (single-byte count on the wire).</summary>
public const int MaxDataBytes = 236;
/// <summary>True: PLC-5 range READ (cmd 0x0F fnc 0x01) is capture-verified.</summary>
public static bool Plc5Verified { get; } = true;
/// <summary>False: no PLC-5 write capture exists, so no PLC-5 write builder ships.</summary>
public static bool Plc5WriteVerified { get; } = false;
/// <summary>Requestor vendor id the native transport puts in Execute PCCC: libplctag's, from the captures
/// (<c>07 3D F3 45 43 50 21</c> in <c>slc500-read-n7.hex</c>).</summary>
/// <remarks>Not the CIP originator vendor (<see cref="ConnectionOptions.VendorId"/>). ab_server answers every
/// PCCC request with this fixed requestor id (<c>PCCC_RESP_PREFIX</c> in its <c>pccc.c</c>) instead of
/// echoing the request's, while real controllers echo whatever was sent. Sending libplctag's identity is
/// the only choice that lets the echo check in <see cref="ParseReply"/> pass against both (found by
/// Task 17 slc500: with the 0x1337 originator identity every native PCCC reply was BadReply).</remarks>
public const ushort RequestorVendor = 0xF33D;
/// <summary>Requestor serial number paired with <see cref="RequestorVendor"/> (libplctag's, from the captures).</summary>
public const uint RequestorSerial = 0x21504345;
/// <summary>Wraps a PCCC command in Execute PCCC: <c>4B 02 20 67 24 01</c> + requestor id (7, vendor, serial) + PCCC.</summary>
public static byte[] ExecutePccc(ushort requestorVendor, uint requestorSerial, ReadOnlySpan<byte> pccc) =>
new CipWriter().U8(0x4B).U8(2).U8(0x20).U8(0x67).U8(0x24).U8(0x01)
.U8(7).U16(requestorVendor).U32(requestorSerial).Bytes(pccc).ToArray();
private static void Field(CipWriter w, int v)
{
if (v < 0) throw new AbTransportException(AbStatus.OutOfBounds, $"Negative PCCC field {v}.");
if (v > ushort.MaxValue) throw new AbTransportException(AbStatus.OutOfBounds, $"PCCC field {v} exceeds 65535.");
if (v < 0xFF) w.U8((byte)v); else w.U8(0xFF).U16((ushort)v);
}
private static void CheckCount(int n)
{
if (n < 0 || n > MaxDataBytes)
throw new AbTransportException(AbStatus.TooLarge, $"PCCC byte count {n} exceeds {MaxDataBytes}.");
}
private static void Logical(CipWriter w, PcccAddress a)
{
Field(w, a.EffectiveFileNumber);
w.U8(a.FileTypeCode);
Field(w, a.Element);
Field(w, a.ResolveSubElement().Word);
}
/// <summary>Protected typed logical read with 3 address fields (0x0F/0xA2).</summary>
public static byte[] SlcTypedRead(ushort tns, PcccAddress addr, int byteCount)
{
CheckCount(byteCount);
var w = new CipWriter().U8(0x0F).U8(0).U16(tns).U8(0xA2).U8((byte)byteCount);
Logical(w, addr);
return w.ToArray();
}
/// <summary>Protected typed logical write with 3 address fields (0x0F/0xAA). SLC/MicroLogix/LogixPccc
/// only: it must not be used for PLC-5 (the family guard lives in the AbLegacy native runtime, Task 14).</summary>
public static byte[] SlcTypedWrite(ushort tns, PcccAddress addr, ReadOnlySpan<byte> data)
{
CheckCount(data.Length);
var w = new CipWriter().U8(0x0F).U8(0).U16(tns).U8(0xAA).U8((byte)data.Length);
Logical(w, addr);
return w.Bytes(data).ToArray();
}
/// <summary>PLC-5 range read (0x0F/0x01), as captured from libplctag:
/// <c>offset u16 (0), totalTrans u16, levelMask u8 (0x06 = file+element), file*, element*, byteCount u8</c>.
/// <c>totalTrans</c> counts 16-bit WORDS (DF1 word range read; the capture shows 1 for one 2-byte N element).
/// Only N-file reads are capture-verified, other PLC-5 file types are unverified; sub-element addressing is not and throws Unsupported.</summary>
public static byte[] Plc5RangeRead(ushort tns, PcccAddress addr, ushort totalTrans, int byteCount)
{
CheckCount(byteCount);
if (addr.ResolveSubElement().Word != 0)
throw new AbTransportException(AbStatus.Unsupported, "PLC-5 sub-element addressing is not capture-verified.");
var w = new CipWriter().U8(0x0F).U8(0).U16(tns).U8(0x01).U16(0).U16(totalTrans).U8(0x06);
Field(w, addr.EffectiveFileNumber);
Field(w, addr.Element);
return w.U8((byte)byteCount).ToArray();
}
/// <summary>Validates an Execute PCCC reply and returns the PCCC data bytes.</summary>
/// <param name="reply">The parsed CIP reply.</param>
/// <param name="expectedTns">The request's TNS; checked before STS so a stale error reply is BadReply.</param>
/// <param name="expectedRequestorId">Optional full echoed requestor id (length byte included) to compare.</param>
public static ReadOnlyMemory<byte> ParseReply(CipReply reply, ushort expectedTns, byte[]? expectedRequestorId = null)
{
if (reply.GeneralStatus != 0)
throw new AbTransportException(reply.Status, $"Execute PCCC CIP status 0x{reply.GeneralStatus:X2}.");
var r = new CipReader(reply.Data.Span);
var idLen = r.U8();
if (idLen < 1) throw new AbTransportException(AbStatus.BadReply, "Empty requestor id in PCCC reply.");
var idBody = r.Bytes(idLen - 1);
if (expectedRequestorId is not null
&& !(expectedRequestorId.Length == idLen && expectedRequestorId[0] == idLen
&& idBody.SequenceEqual(expectedRequestorId.AsSpan(1))))
throw new AbTransportException(AbStatus.BadReply, "PCCC reply requestor id does not match the request.");
var cmd = r.U8();
if (cmd != 0x4F)
throw new AbTransportException(AbStatus.BadReply, $"PCCC reply command 0x{cmd:X2} != 0x4F.");
var sts = r.U8();
var tns = r.U16();
if (tns != expectedTns)
throw new AbTransportException(AbStatus.BadReply, $"PCCC TNS {tns} != {expectedTns}.");
byte? ext = sts == 0xF0 ? r.U8() : null;
if (sts != 0)
throw new AbTransportException(AbStatusCodes.FromPccc(sts, ext),
$"PCCC STS 0x{sts:X2}{(ext is { } e ? $" EXT 0x{e:X2}" : "")}.");
return reply.Data[r.Position..];
}
}
@@ -0,0 +1,26 @@
using System.Buffers.Binary;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
/// <summary>Bounds-checked little-endian reader. Any overrun throws AbTransportException(BadReply).</summary>
public ref struct CipReader(ReadOnlySpan<byte> data)
{
private readonly ReadOnlySpan<byte> _data = data;
public int Position { get; private set; }
public readonly int Remaining => _data.Length - Position;
private ReadOnlySpan<byte> Take(int n)
{
if (n < 0 || Position + n > _data.Length)
throw new AbTransportException(AbStatus.BadReply, $"Reply truncated: need {n} bytes at {Position}, have {Remaining}.");
var s = _data.Slice(Position, n);
Position += n;
return s;
}
public byte U8() => Take(1)[0];
public ushort U16() => BinaryPrimitives.ReadUInt16LittleEndian(Take(2));
public uint U32() => BinaryPrimitives.ReadUInt32LittleEndian(Take(4));
public ulong U64() => BinaryPrimitives.ReadUInt64LittleEndian(Take(8));
public ReadOnlySpan<byte> Bytes(int n) => Take(n);
public ReadOnlySpan<byte> Rest() => Take(Remaining);
}
@@ -0,0 +1,29 @@
using System.Buffers.Binary;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
/// <summary>Growable little-endian writer for CIP / EIP frames.</summary>
public sealed class CipWriter
{
private byte[] _buf = new byte[256];
public int Length { get; private set; }
private Span<byte> Take(int n)
{
if (Length + n > _buf.Length) Array.Resize(ref _buf, Math.Max(_buf.Length * 2, Length + n));
var s = _buf.AsSpan(Length, n);
Length += n;
return s;
}
public CipWriter U8(byte v) { Take(1)[0] = v; return this; }
public CipWriter U16(ushort v) { BinaryPrimitives.WriteUInt16LittleEndian(Take(2), v); return this; }
public CipWriter U32(uint v) { BinaryPrimitives.WriteUInt32LittleEndian(Take(4), v); return this; }
public CipWriter U64(ulong v) { BinaryPrimitives.WriteUInt64LittleEndian(Take(8), v); return this; }
public CipWriter Bytes(ReadOnlySpan<byte> v) { v.CopyTo(Take(v.Length)); return this; }
public CipWriter PadToEven() { if ((Length & 1) == 1) U8(0); return this; }
/// <summary>Overwrite a u16 already written at <paramref name="offset"/> (length back-patching).</summary>
public void PatchU16(int offset, ushort v) => BinaryPrimitives.WriteUInt16LittleEndian(_buf.AsSpan(offset, 2), v);
public byte[] ToArray() => _buf.AsSpan(0, Length).ToArray();
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests"/>
<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>
@@ -32,6 +32,23 @@ public sealed class AbCipDriverOptions
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <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": the node-level
/// <c>Drivers:AbCip:DefaultTransport</c> appsetting, which itself defaults to <see cref="AbCipTransport.Native"/>;
/// an explicit value always wins. The effective transport is resolved in one place,
/// <c>AbCipTransportResolver</c>. Set <see cref="AbCipTransport.Libplctag"/> to fall back.
/// <para>Changing this takes effect when the driver instance is RECREATED, not on
/// <c>ReinitializeAsync</c>: the factories are chosen once in the constructor and held across
/// Shutdown+Initialize. A deploy that changes the driver config stops and respawns the instance
/// (<c>DriverSpawnPlanner</c>, #516), so an operator edit does recreate it.</para>
/// </summary>
/// <remarks>Null is never written: an absent key IS the "use the default" encoding, so a round-trip
/// through any serializer (the AdminUI form included) keeps a defaulted driver defaulted rather than
/// pinning it to whatever the default happened to be on the day it was saved.</remarks>
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public AbCipTransport? Transport { get; init; }
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
public AbCipProbeOptions Probe { get; init; } = new();
@@ -119,11 +136,9 @@ public sealed class AbCipDriverOptions
/// touching every other device on the same family.</param>
/// <param name="ConnectionSize">Per-device override for the Forward Open
/// ConnectionSize (Large Forward Open packet size in bytes). <c>null</c> inherits the family
/// profile's <c>DefaultConnectionSize</c>. Honoured by the driver layer; the underlying
/// libplctag 1.5.2 wrapper has no direct <c>ConnectionSize</c> property, so the value is
/// plumbed through <see cref="AbCipTagCreateParams"/> for forward-compat with future wrapper
/// versions or a custom tag-attribute path; current builds use the family profile default at
/// the wire layer regardless.</param>
/// profile's <c>DefaultConnectionSize</c>. The native transport honours it on the wire (its Forward
/// Open size). The libplctag transport ignores it: the libplctag 1.5.2 wrapper has no
/// <c>ConnectionSize</c> property, so libplctag opens with its own size for the <c>plc</c> attribute.</param>
public sealed record AbCipDeviceOptions(
string HostAddress,
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
@@ -0,0 +1,14 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Which CIP transport stack an AbCip driver instance (and its Test Connect probe / CLI run)
/// talks to the controller through. Serialises as a STRING in driver config JSON.
/// </summary>
public enum AbCipTransport
{
/// <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). The default.</summary>
Native,
}
@@ -1,4 +1,4 @@
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
@@ -137,22 +137,31 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <summary>Initializes a new instance of the <see cref="AbCipDriver"/> class.</summary>
/// <param name="options">The driver configuration options.</param>
/// <param name="driverInstanceId">A unique identifier for this driver instance.</param>
/// <param name="tagFactory">Optional factory for creating tag runtimes; uses libplctag default if null.</param>
/// <param name="enumeratorFactory">Optional factory for enumerating tags; uses libplctag default if null.</param>
/// <param name="templateReaderFactory">Optional factory for reading UDT templates; uses libplctag default if null.</param>
/// <param name="tagFactory">Optional factory for creating tag runtimes; when null the resolved transport's (see <see cref="AbCipTransportResolver"/>) is used.</param>
/// <param name="enumeratorFactory">Optional factory for enumerating tags; when null the resolved transport's is used.</param>
/// <param name="templateReaderFactory">Optional factory for reading UDT templates; when null the resolved transport's is used.</param>
/// <param name="logger">Optional logger; uses null logger if not provided.</param>
/// <param name="defaultTransport">The node-level default for an absent <c>transport</c> key
/// (<c>Drivers:AbCip:DefaultTransport</c>); null means the compiled <see cref="AbCipTransportResolver.Default"/>.</param>
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
IAbCipTagFactory? tagFactory = null,
IAbCipTagEnumeratorFactory? enumeratorFactory = null,
IAbCipTemplateReaderFactory? templateReaderFactory = null,
ILogger<AbCipDriver>? logger = null)
ILogger<AbCipDriver>? logger = null,
AbCipTransport? defaultTransport = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_tagFactory = tagFactory ?? new LibplctagTagFactory();
_enumeratorFactory = enumeratorFactory ?? new LibplctagTagEnumeratorFactory();
_templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory();
// Chosen once here and held across ReinitializeAsync (Shutdown+Initialize): a changed
// Transport in a reinit config is inert; a deploy recreates the instance instead.
var nodeDefault = defaultTransport ?? AbCipTransportResolver.Default;
_transport = AbCipTransportResolver.Resolve(options, nodeDefault);
_transportDescription = AbCipTransportResolver.Describe(options.Transport, nodeDefault);
var defaults = AbCipTransportFactories.For(_transport);
_tagFactory = tagFactory ?? defaults.Tags;
_enumeratorFactory = enumeratorFactory ?? defaults.Enumerators;
_templateReaderFactory = templateReaderFactory ?? defaults.Templates;
_logger = logger ?? NullLogger<AbCipDriver>.Instance;
_resolver = new EquipmentTagRefResolver<AbCipTagDefinition>(
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
@@ -172,6 +181,32 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval, _logger);
}
/// <summary>The tag factory chosen at construction; exposed so tests can pin transport selection.</summary>
internal IAbCipTagFactory TagFactory => _tagFactory;
/// <summary>The effective transport resolved at construction (see <see cref="AbCipTransportResolver"/>).
/// Describes the OPTION only: injected factories (tests) are used regardless.</summary>
public AbCipTransport EffectiveTransport => _transport;
// The transport chosen at construction (see the ctor); logged at every init so an operator can tell
// from the node log which stack serves this instance.
private readonly AbCipTransport _transport;
// Configured-vs-effective rendering of _transport for the init log, e.g. "Native (default)".
private readonly string _transportDescription;
// Tags whose type-mismatch Warning has already been logged (final review I4): a mis-authored tag is
// polled forever, so the Warning is once per tag per driver instance, not once per poll.
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _typeMismatchWarned =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>Logs a type-mismatch Warning the first time <paramref name="tagName"/> reports one on this instance.</summary>
private void WarnTypeMismatchOnce(string tagName, string message, params object?[] args)
{
if (_typeMismatchWarned.TryAdd(tagName, 0))
_logger.LogWarning(message, args);
}
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
@@ -276,6 +311,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
_logger.LogInformation(
"AbCip driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}",
_driverInstanceId, _transportDescription, _tagFactory.GetType().Name);
try
{
if (!string.IsNullOrWhiteSpace(driverConfigJson))
@@ -781,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.
@@ -789,12 +840,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// mirrors the probe loop's recreate-on-failure behaviour.
EvictRuntime(device, def.Name);
results[fb.OriginalIndex] = new DataValueSnapshot(null,
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
AbCipStatusMapper.MapStatus(status), null, now);
_logger.LogWarning(
"AbCip read returned non-zero libplctag status {LibplctagStatus} for tag {Tag} on device {Device}; " +
"AbCip read returned non-zero transport status {Status} for tag {Tag} on device {Device}; " +
"evicting cached runtime so next call re-creates it",
status, reference, def.DeviceHostAddress);
return ReadOutcome.Degraded($"libplctag status {status} reading {reference}");
return ReadOutcome.Degraded($"transport status {status} reading {reference}");
}
var tagPath = AbCipTagPath.TryParse(def.TagPath);
@@ -808,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
@@ -832,10 +885,25 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
throw;
}
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
{
// A deterministic local refusal — not a comms fault: BadNotSupported, keep the handle, no
// health signal (Task 22 review r1).
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
return ReadOutcome.None;
}
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
{
// Final review I4: deterministic, keep the handle, no health signal, warn once per tag.
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
WarnTypeMismatchOnce(def.Name, "AbCip read of {Tag} on device {Device} refused: {Reason} (logged once per tag)",
reference, def.DeviceHostAddress, ate.Message);
return ReadOutcome.None;
}
catch (Exception ex)
{
// Transport exception — evict so the next read creates a fresh handle. A libplctag
// exception carries its Status, so a tag the controller does not have reads
// Transport exception — evict so the next read creates a fresh handle. An AbTransportException
// carries its AbStatus, so a tag the controller does not have reads
// BadNodeIdUnknown and a decode past the buffer BadOutOfRange, not a comms error.
EvictRuntime(device, def.Name);
results[fb.OriginalIndex] = new DataValueSnapshot(null,
@@ -850,7 +918,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <summary>
/// Perform one whole-UDT read on the parent tag, then decode each
/// grouped member from the runtime's buffer at its computed byte offset. A per-group
/// failure (parent read raised, non-zero libplctag status, or missing device) stamps
/// failure (parent read raised, non-zero transport status, or missing device) stamps
/// the mapped fault across every grouped member only — sibling groups + the
/// per-tag fallback list are unaffected.
/// </summary>
@@ -878,16 +946,26 @@ 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);
var mapped = AbCipStatusMapper.MapLibplctagStatus(status);
var mapped = AbCipStatusMapper.MapStatus(status);
StampGroupStatus(group, results, now, mapped);
_logger.LogWarning(
"AbCip whole-UDT read returned non-zero libplctag status {LibplctagStatus} for parent {Parent} " +
"AbCip whole-UDT read returned non-zero transport status {Status} for parent {Parent} " +
"on device {Device}; {MemberCount} member values stamped with mapped status",
status, group.ParentName, parent.DeviceHostAddress, group.Members.Count);
return ReadOutcome.Degraded($"libplctag status {status} reading UDT {group.ParentName}");
return ReadOutcome.Degraded($"transport status {status} reading UDT {group.ParentName}");
}
foreach (var member in group.Members)
@@ -906,6 +984,19 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
throw;
}
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
{
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNotSupported);
return ReadOutcome.None;
}
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
{
StampGroupStatus(group, results, now, AbCipStatusMapper.BadTypeMismatch);
WarnTypeMismatchOnce(parent.Name,
"AbCip whole-UDT read of {Parent} on device {Device} refused: {Reason} (logged once per tag)",
group.ParentName, parent.DeviceHostAddress, ate.Message);
return ReadOutcome.None;
}
catch (Exception ex)
{
EvictRuntime(device, parent.Name);
@@ -989,12 +1080,20 @@ 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.MapLibplctagStatus(status));
results[i] = new WriteResult(AbCipStatusMapper.MapStatus(status));
_logger.LogWarning(
"AbCip write returned non-zero libplctag status {LibplctagStatus} for tag {Tag} on device {Device}; " +
"AbCip write returned non-zero transport status {Status} for tag {Tag} on device {Device}; " +
"evicting cached runtime so next call re-creates it",
status, w.FullReference, def.DeviceHostAddress);
}
@@ -1042,6 +1141,25 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
"AbCip write value out of range for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
}
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
{
// A deterministic local refusal (e.g. the native runtime cannot type the write) — not a comms
// fault: BadNotSupported, keep the handle, leave health alone (Task 22 review r1).
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
_logger.LogWarning(ate,
"AbCip write refused (not supported) for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
}
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
{
// Final review I4: the controller's type disagrees with the authored one (native type gate, or
// CIP 0xFF/0x2107 from the controller) — deterministic: BadTypeMismatch, keep the handle, leave
// health alone, warn once per tag. Retrying cannot succeed until the config is corrected.
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
WarnTypeMismatchOnce(def.Name,
"AbCip write to {Tag} on device {Device} refused: {Reason} (logged once per tag)",
w.FullReference, def.DeviceHostAddress, ate.Message);
}
catch (Exception ex)
{
// Transport / wire error — evict so the next write creates a fresh handle.
@@ -1076,7 +1194,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
var readStatus = parentRuntime.GetStatus();
if (readStatus != 0) return AbCipStatusMapper.MapLibplctagStatus(readStatus);
if (readStatus != 0) return AbCipStatusMapper.MapStatus(readStatus);
var current = Convert.ToInt32(parentRuntime.DecodeValue(AbCipDataType.DInt, bitIndex: null) ?? 0);
var updated = Convert.ToBoolean(value)
@@ -1088,7 +1206,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var writeStatus = parentRuntime.GetStatus();
return writeStatus == 0
? AbCipStatusMapper.Good
: AbCipStatusMapper.MapLibplctagStatus(writeStatus);
: AbCipStatusMapper.MapStatus(writeStatus);
}
finally
{
@@ -1171,7 +1289,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// path then boxes them into a typed CLR array. Scalar tags pass count 1 + IsArray false.
var runtime = _tagFactory.Create(
device.BuildCreateParams(
parsed.ToLibplctagName(), _options.Timeout, def.ElementCount, IsArrayTag(def)));
parsed.ToLibplctagName(), _options.Timeout, def.ElementCount, IsArrayTag(def),
authoredType: def.DataType));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
@@ -1183,10 +1302,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// not answer. "Tag not found" means the controller replied over a working session; it
// is a config error on this one tag, and counting it as a failed Forward-Open throttled
// every sibling tag on the device (seen live: one typo blacked out the whole device).
if (ex is LibPlcTagException lpe
&& AbCipStatusMapper.TryGetStatus(lpe, out var status)
&& status != Status.Ok
&& AbCipDriverProbe.IsReachableStatus(status))
if (ex is AbTransportException ate
&& ate.Status != AbStatus.Ok
&& AbCipDriverProbe.IsReachableStatus(ate.Status))
device.Backoff.RecordSuccess();
else
device.Backoff.RecordFailure(DateTime.UtcNow);
@@ -1211,7 +1329,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// Evict the runtime for <paramref name="tagName"/> from the device's cache and dispose
/// it so the next read/write call re-creates and re-initializes a fresh handle.
/// Called from <see cref="ReadSingleAsync"/>, <see cref="ReadGroupAsync"/>, and
/// <see cref="WriteAsync"/> after a non-zero libplctag status or transport exception —
/// <see cref="WriteAsync"/> after a non-zero transport status or transport exception —
/// mirroring the probe loop's recreate-on-failure behaviour.
/// </summary>
private static void EvictRuntime(DeviceState device, string tagName)
@@ -1286,7 +1404,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
// Controller-discovered tags — opt-in via EnableControllerBrowse. The real @tags
// walker (LibplctagTagEnumerator) is the factory default,
// walker (SymbolListingEnumerator) is the factory default,
// so leaving the flag off keeps the strict-config path for deployments where only
// declared tags should appear.
if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
@@ -1687,9 +1805,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// length for a 1-D array tag (Phase 4c). Coerced to a minimum of 1.</param>
/// <param name="isArray">Review I-1 — the EXPLICIT array signal threaded through so a
/// 1-element array (<paramref name="elementCount"/> 1) is still read as an array.</param>
/// <param name="authoredType">The tag definition's authored type, for the native runtime's
/// returned-type check; null skips it.</param>
/// <returns>The computed tag creation parameters.</returns>
public AbCipTagCreateParams BuildCreateParams(
string tagName, TimeSpan timeout, int elementCount = 1, bool isArray = false) => new(
string tagName, TimeSpan timeout, int elementCount = 1, bool isArray = false,
AbCipDataType? authoredType = null) => new(
Gateway: ParsedAddress.Gateway,
Port: ParsedAddress.Port,
CipPath: ParsedAddress.CipPath,
@@ -1699,7 +1820,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
AllowPacking: Options.AllowPacking ?? Profile.SupportsRequestPacking,
ConnectionSize: Options.ConnectionSize ?? Profile.DefaultConnectionSize,
ElementCount: elementCount < 1 ? 1 : elementCount,
IsArray: isArray);
IsArray: isArray,
AuthoredType: authoredType,
UseConnectedMessaging: Profile.SupportsConnectedMessaging);
/// <summary>Disposes all runtime tag handles and clears the caches.</summary>
public void DisposeHandles()
@@ -1,4 +1,5 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
@@ -15,13 +16,22 @@ public static class AbCipDriverFactoryExtensions
public const string DriverTypeName = "AbCip";
/// <summary>
/// Registers the AB CIP driver factory with the driver registry.
/// Registers the AB CIP driver factory with the driver registry. The optional
/// <paramref name="loggerFactory"/> is captured at registration time and used to construct an
/// <see cref="ILogger{AbCipDriver}"/> per driver instance — without it the driver runs with the
/// null logger, so none of its diagnostics (including the per-init transport line) reach the
/// node log. Mirrors the AbLegacy / Modbus registration pattern.
/// </summary>
/// <param name="registry">The driver factory registry to register with.</param>
public static void Register(DriverFactoryRegistry registry)
/// <param name="loggerFactory">Optional logger factory for driver instances.</param>
/// <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, CreateInstance);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, defaultTransport));
}
/// <summary>
@@ -31,15 +41,27 @@ public static class AbCipDriverFactoryExtensions
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
/// <returns>A configured <see cref="AbCipDriver"/> instance.</returns>
internal static AbCipDriver CreateInstance(string driverInstanceId, string driverConfigJson)
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
/// <summary>Creates an instance of the AB CIP driver with an optional logger.</summary>
/// <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,
AbCipTransport defaultTransport = AbCipTransportResolver.Default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
var options = ParseOptions(driverInstanceId, driverConfigJson);
return new AbCipDriver(options, driverInstanceId);
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"/> (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.
@@ -81,6 +103,11 @@ public static class AbCipDriverFactoryExtensions
ProbeTagPath = dto.Probe?.ProbeTagPath,
},
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
// Absent ⇒ null ("use the default", resolved by AbCipTransportResolver); a bad value still
// fails exactly as any other bad enum field does.
Transport = string.IsNullOrWhiteSpace(dto.Transport)
? null
: ParseEnum<AbCipTransport>(dto.Transport, tagName: null, driverInstanceId, "Transport"),
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
EnableAlarmProjection = dto.EnableAlarmProjection ?? false,
AlarmPollInterval = TimeSpan.FromMilliseconds(dto.AlarmPollIntervalMs ?? 1_000),
@@ -103,19 +130,25 @@ public static class AbCipDriverFactoryExtensions
private static TimeSpan PositiveTimeoutOrDefault(int? ms, int defaultMs) =>
TimeSpan.FromMilliseconds(ms is int v and > 0 ? v : defaultMs);
// tagName null = a driver-level field: the error text names the driver config, not a tag.
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field,
T? fallback = null) where T : struct, Enum
{
var subject = tagName is null ? $"config in '{driverInstanceId}'" : $"tag '{tagName}'";
if (string.IsNullOrWhiteSpace(raw))
{
if (fallback.HasValue) return fallback.Value;
throw new InvalidOperationException(
$"AB CIP tag '{tagName ?? "<unnamed>"}' in '{driverInstanceId}' missing {field}");
tagName is null
? $"AB CIP {subject} missing {field}"
: $"AB CIP {subject} in '{driverInstanceId}' missing {field}");
}
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
// Enum.TryParse also accepts any integer string ("7"), defined or not; an undefined value would
// otherwise fall through to a switch's default arm silently (Task 22 review r1 M2).
return Enum.TryParse<T>(raw, ignoreCase: true, out var v) && Enum.IsDefined(v)
? v
: throw new InvalidOperationException(
$"AB CIP tag '{tagName}' has unknown {field} '{raw}'. " +
$"AB CIP {subject} has unknown {field} '{raw}'. " +
$"Expected one of {string.Join(", ", Enum.GetNames<T>())}");
}
@@ -128,6 +161,11 @@ 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 the node default.
/// </summary>
public string? Transport { get; init; }
/// <summary>
/// Gets or sets the timeout in milliseconds for operations.
/// </summary>
@@ -2,7 +2,7 @@ using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
@@ -11,26 +11,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Two-phase Test-Connect probe for the <see cref="AbCipDriverOptions"/>-shaped driver config.
/// Phase 1: bare TCP connect to the first device's gateway host + EtherNet/IP port to
/// quickly reject unreachable targets. Phase 2: initialises a libplctag <see cref="Tag"/>
/// quickly reject unreachable targets. Phase 2: initialises a tag runtime
/// against the same device to open a real EIP session and perform a CIP Forward Open —
/// confirming the remote endpoint actually speaks EtherNet/IP CIP, not just accepts TCP.
/// A device that accepts the TCP connection but is not a CIP controller (wrong CIP path,
/// non-CIP server) returns <c>Ok = false</c> with a "handshake failed" message instead of
/// a false-positive green tick.
/// <para>
/// Status-code discrimination: libplctag statuses that indicate the controller answered
/// the CIP session but could not resolve the probe tag
/// (<see cref="Status.ErrorNotFound"/>, <see cref="Status.ErrorNoMatch"/>,
/// <see cref="Status.ErrorBadDevice"/>) are treated as "reachable — Ok = true" because
/// a non-existent tag name proves CIP connectivity. Statuses that indicate a transport or
/// session failure (<see cref="Status.ErrorTimeout"/>, <see cref="Status.ErrorBadConnection"/>,
/// <see cref="Status.ErrorBadGateway"/>, <see cref="Status.ErrorWinsock"/>,
/// <see cref="Status.ErrorOpen"/>, <see cref="Status.ErrorClose"/>,
/// <see cref="Status.ErrorRead"/>, <see cref="Status.ErrorWrite"/>,
/// <see cref="Status.ErrorBadReply"/>, <see cref="Status.ErrorRemoteErr"/>,
/// <see cref="Status.ErrorPartial"/>, <see cref="Status.ErrorAbort"/>) are treated as
/// "handshake failed — Ok = false". All other error statuses default to
/// "handshake failed" (conservative).
/// Status-code discrimination: <c>AbStatus.Ok</c> and <c>AbStatus.NotFound</c> mean the controller
/// answered the CIP session (a non-existent probe tag name still proves CIP connectivity) and are
/// treated as "reachable - Ok = true". Every other status (timeout, bad connection, bad reply,
/// partial, abort, ...) is a transport or session failure: "handshake failed - Ok = false".
/// </para>
/// </summary>
public sealed class AbCipDriverProbe : IDriverProbe
@@ -42,6 +33,24 @@ public sealed class AbCipDriverProbe : IDriverProbe
Converters = { new JsonStringEnumConverter() },
};
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>
/// <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";
@@ -122,9 +131,10 @@ public sealed class AbCipDriverProbe : IDriverProbe
TagName: tagName,
Timeout: timeout,
AllowPacking: firstDevice?.AllowPacking ?? profile.SupportsRequestPacking,
ConnectionSize: firstDevice?.ConnectionSize ?? profile.DefaultConnectionSize);
ConnectionSize: firstDevice?.ConnectionSize ?? profile.DefaultConnectionSize,
UseConnectedMessaging: profile.SupportsConnectedMessaging);
var rt = new LibplctagTagRuntime(p);
var rt = (_tagFactory ?? AbCipTransportFactories.For(AbCipTransportResolver.Resolve(opts, _defaultTransport)).Tags).Create(p);
try
{
await rt.InitializeAsync(cts.Token);
@@ -132,10 +142,10 @@ public sealed class AbCipDriverProbe : IDriverProbe
// InitializeAsync completed without throwing — either the tag was found (Ok) or
// libplctag returned a non-exception status. Check via GetStatus().
var statusCode = (Status)rt.GetStatus();
var statusCode = (AbStatus)rt.GetStatus();
if (IsReachableStatus(statusCode))
{
var msg = statusCode == Status.Ok
var msg = statusCode == AbStatus.Ok
? "CIP session OK"
: "CIP session OK (controller reachable; probe tag not found)";
return new(true, msg, sw.Elapsed);
@@ -148,9 +158,9 @@ public sealed class AbCipDriverProbe : IDriverProbe
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (LibPlcTagException ex)
catch (AbTransportException ex)
{
// LibPlcTagException carries the Status; classify as reachable vs transport failure.
// AbTransportException carries the Status; classify as reachable vs transport failure.
if (IsReachableException(ex))
{
sw.Stop();
@@ -170,42 +180,23 @@ public sealed class AbCipDriverProbe : IDriverProbe
}
/// <summary>
/// Returns <c>true</c> for libplctag statuses that indicate the CIP controller answered
/// Returns <c>true</c> for transport statuses that indicate the CIP controller answered
/// but the probe tag name was not found or did not match — sufficient to confirm CIP
/// reachability. Returns <c>true</c> for <see cref="Status.Ok"/> (tag was found).
/// reachability. Returns <c>true</c> for <c>Ok</c> (tag was found).
/// </summary>
internal static bool IsReachableStatus(Status s) => s is
Status.Ok or
Status.ErrorNotFound or
Status.ErrorNoMatch or
Status.ErrorBadDevice;
internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
/// <summary>
/// Decides whether a <see cref="LibPlcTagException"/> is a "controller answered CIP but the
/// Decides whether an <see cref="AbTransportException"/> is a "controller answered CIP but the
/// probe tag did not resolve" condition (reachable) or a session / transport failure
/// (unreachable), by recovering the <see cref="Status"/> the wrapper threw on and deferring
/// to <see cref="IsReachableStatus"/> — one classification, not two that can drift.
/// (unreachable), deferring to <see cref="IsReachableStatus"/> — one classification, not two.
/// </summary>
/// <param name="ex">The exception libplctag threw out of <c>InitializeAsync</c>.</param>
/// <param name="ex">The exception thrown out of <c>InitializeAsync</c>.</param>
/// <returns><c>true</c> when the controller answered; <c>false</c> for a transport/session failure.</returns>
/// <remarks>
/// <b>The status arrives as the managed enum NAME.</b> <c>NativeTagWrapper.ThrowIfStatusNotOk</c>
/// constructs the exception with <c>Status.ToString()</c>, so the message is exactly
/// <c>"ErrorNotFound"</c> — never the native C spelling <c>"STATUS_NOT_FOUND"</c> this method
/// used to look for. Those substrings could not match, so the reachable classification was
/// unreachable on this path — and this IS the path a probe takes whenever its tag does not
/// resolve, which is every Test Connect against a device with no authored tags. The native
/// spellings are kept as a secondary match in case a future wrapper reports them.
/// </remarks>
internal static bool IsReachableException(LibPlcTagException ex)
internal static bool IsReachableException(AbTransportException ex)
{
var msg = ex.Message ?? string.Empty;
if (AbCipStatusMapper.TryGetStatus(ex, out var status))
return IsReachableStatus(status);
return msg.Contains("NOT_FOUND", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("NO_MATCH", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("BAD_DEVICE", StringComparison.OrdinalIgnoreCase);
ArgumentNullException.ThrowIfNull(ex);
return IsReachableStatus(ex.Status);
}
private static (AbCipHostAddress? parsed, AbCipDeviceOptions? device) ExtractTarget(AbCipDriverOptions opts)
@@ -1,4 +1,4 @@
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
@@ -26,8 +26,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// writes during download / test-mode transitions).</item>
/// <item>0x16 object does not exist — <c>BadNodeIdUnknown</c>.</item>
/// <item>0x1E embedded service error — unwrap to the extended status when possible.</item>
/// <item>libplctag.NET <see cref="Status"/> errors — mapped per-member by
/// <see cref="MapLibplctagStatus(Status)"/>: timeout, not-found, not-allowed, and
/// <item>Transport <see cref="AbStatus"/> errors — mapped per-member by
/// <see cref="MapStatus(AbStatus)"/>: timeout, not-found, not-allowed, and
/// out-of-bounds get their specific OPC UA codes; the remaining transport errors
/// fold into <c>BadCommunicationError</c>.</item>
/// </list>
@@ -64,66 +64,42 @@ public static class AbCipStatusMapper
};
/// <summary>
/// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here
/// is <c>(int)Tag.GetStatus()</c> — i.e. the underlying value of the libplctag.NET
/// <see cref="Status"/> enum, NOT a raw native <c>PLCTAG_ERR_*</c> constant. The wrapper
/// renumbers the native codes into a contiguous enum, so this method switches on the
/// <see cref="Status"/> members directly to stay correct if the wrapper renumbers again.
/// <see cref="Status.Ok"/> is success; <see cref="Status.Pending"/> is an in-flight
/// operation; every other (negative) member is an error.
/// Map a transport status code to an OPC UA StatusCode. The integer is
/// <see cref="IAbCipTagRuntime.GetStatus"/>, i.e. the underlying value of <see cref="AbStatus"/>.
/// </summary>
/// <param name="status">The libplctag status code as an integer.</param>
/// <param name="status">The <see cref="AbStatus"/> as an integer.</param>
/// <returns>The mapped OPC UA StatusCode value.</returns>
public static uint MapLibplctagStatus(int status) => MapLibplctagStatus((Status)status);
public static uint MapStatus(int status) => MapStatus((AbStatus)status);
/// <summary>
/// Recover the libplctag <see cref="Status"/> a <see cref="LibPlcTagException"/> was thrown on.
/// The wrapper builds the exception from <c>Status.ToString()</c>, so the message is the enum
/// name (<c>"ErrorNotFound"</c>).
/// Map an <see cref="AbStatus"/> to an OPC UA StatusCode. Timeout, not-found, not-allowed and
/// out-of-bounds get their specific codes; the remaining transport errors fold into
/// <see cref="BadCommunicationError"/>.
/// </summary>
/// <param name="ex">The exception.</param>
/// <param name="status">The recovered status.</param>
/// <returns><c>true</c> when the message names a defined <see cref="Status"/>.</returns>
public static bool TryGetStatus(LibPlcTagException ex, out Status status)
/// <param name="status">The transport status.</param>
/// <returns>The mapped OPC UA StatusCode value.</returns>
public static uint MapStatus(AbStatus status) => status switch
{
ArgumentNullException.ThrowIfNull(ex);
return Enum.TryParse((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out status)
&& Enum.IsDefined(status);
}
AbStatus.Ok => Good,
AbStatus.Pending => GoodMoreData,
AbStatus.Timeout => BadTimeout,
AbStatus.NotFound => BadNodeIdUnknown,
AbStatus.NotAllowed => BadNotWritable,
AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall => BadOutOfRange,
AbStatus.Unsupported => BadNotSupported,
AbStatus.TypeMismatch => BadTypeMismatch,
AbStatus.DeviceStateConflict => BadDeviceFailure,
_ => BadCommunicationError, // BadConnection, BadReply, Partial, Abort
};
/// <summary>
/// Status for an exception thrown while creating / reading a tag: a libplctag exception maps
/// through its <see cref="Status"/> (so a tag the controller does not have reads
/// Status for an exception thrown while creating / reading a tag: a transport exception maps
/// through its <see cref="AbStatus"/> (so a tag the controller does not have reads
/// <see cref="BadNodeIdUnknown"/>, not a comms error); anything else is
/// <see cref="BadCommunicationError"/>.
/// </summary>
/// <param name="ex">The exception.</param>
/// <returns>The OPC UA status code.</returns>
public static uint MapException(Exception ex) =>
ex is LibPlcTagException lpe && TryGetStatus(lpe, out var status)
? MapLibplctagStatus(status)
: BadCommunicationError;
/// <summary>
/// Map a libplctag.NET <see cref="Status"/> enum value to an OPC UA StatusCode. This is
/// the strongly-typed core of the mapper; the <c>int</c> overload exists only for the
/// <see cref="IAbCipTagRuntime.GetStatus"/> seam, which returns the boxed-as-int value.
/// </summary>
/// <param name="status">The libplctag Status enum value.</param>
/// <returns>The mapped OPC UA StatusCode value.</returns>
public static uint MapLibplctagStatus(Status status) => status switch
{
Status.Ok => Good,
Status.Pending => GoodMoreData,
Status.ErrorTimeout => BadTimeout,
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => BadNodeIdUnknown,
Status.ErrorNotAllowed => BadNotWritable,
Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => BadOutOfRange,
Status.ErrorUnsupported or Status.ErrorNotImplemented => BadNotSupported,
Status.ErrorBadConnection or Status.ErrorBadGateway or Status.ErrorBadReply
or Status.ErrorWinsock or Status.ErrorOpen or Status.ErrorClose
or Status.ErrorRead or Status.ErrorWrite or Status.ErrorRemoteErr
or Status.ErrorPartial or Status.ErrorAbort => BadCommunicationError,
_ => BadCommunicationError,
};
ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError;
}
@@ -0,0 +1,14 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>Maps an <see cref="AbCipTransport"/> to the matching trio of runtime factories.</summary>
internal static class AbCipTransportFactories
{
/// <summary>Returns the tag, enumerator and template-reader factories for <paramref name="transport"/>.</summary>
public static (IAbCipTagFactory Tags, IAbCipTagEnumeratorFactory Enumerators, IAbCipTemplateReaderFactory Templates)
For(AbCipTransport transport) => transport switch
{
AbCipTransport.Native => (new NativeAbCipTagFactory(), new NativeAbCipTagEnumeratorFactory(),
new NativeAbCipTemplateReaderFactory()),
_ => (new LibplctagTagFactory(), new LibplctagTagEnumeratorFactory(), new LibplctagTemplateReaderFactory()),
};
}
@@ -0,0 +1,65 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// The ONE place an AbCip driver's effective transport is decided from its configured
/// <see cref="AbCipDriverOptions.Transport"/>. The driver constructor, the Test Connect probe and the
/// CLI (through the driver) all resolve through here, so they cannot disagree about what an absent
/// <c>transport</c> key means.
/// <para>Precedence (final review I1): an explicit per-driver <c>transport</c> key wins; otherwise the
/// node-level default (<c>Drivers:AbCip:DefaultTransport</c>, read in the Host composition root and passed
/// in as <c>nodeDefault</c>); otherwise the compiled <see cref="Default"/>.</para>
/// </summary>
public static class AbCipTransportResolver
{
/// <summary>The compiled default: the transport an absent key selects when no node default is configured.</summary>
public const AbCipTransport Default = AbCipTransport.Native;
/// <summary>Returns the effective transport: an explicit value always wins; null means <paramref name="nodeDefault"/>.</summary>
/// <param name="configured">The configured transport, or null for "use the default".</param>
/// <param name="nodeDefault">The node-level default for drivers with no key (the compiled <see cref="Default"/> unless pinned).</param>
/// <returns>The transport the driver runs on.</returns>
public static AbCipTransport Resolve(AbCipTransport? configured, AbCipTransport nodeDefault = Default) =>
configured ?? nodeDefault;
/// <summary>Returns the effective transport for <paramref name="options"/>.</summary>
/// <param name="options">The driver options.</param>
/// <param name="nodeDefault">The node-level default for drivers with no key.</param>
/// <returns>The transport the driver runs on.</returns>
public static AbCipTransport Resolve(AbCipDriverOptions options, AbCipTransport nodeDefault = Default)
{
ArgumentNullException.ThrowIfNull(options);
return Resolve(options.Transport, nodeDefault);
}
/// <summary>
/// Parses a transport name the way the driver-config <c>transport</c> key and the node-level default
/// are parsed: a defined enum NAME, case-insensitive. Numeric strings ("0", "7") and the flags form
/// ("Native,Libplctag") are rejected — <c>Enum.TryParse</c> alone accepts both.
/// </summary>
/// <param name="raw">The raw value.</param>
/// <param name="transport">The parsed transport.</param>
/// <returns>True when <paramref name="raw"/> names a transport.</returns>
public static bool TryParse(string? raw, out AbCipTransport transport)
{
transport = Default;
if (string.IsNullOrWhiteSpace(raw)) return false;
// Exact name match only: Enum.TryParse would also accept "1" and the flags form "Native,Libplctag".
var name = Enum.GetNames<AbCipTransport>()
.FirstOrDefault(n => string.Equals(n, raw.Trim(), StringComparison.OrdinalIgnoreCase));
return name is not null && Enum.TryParse(name, out transport);
}
/// <summary>
/// Operator-facing description of the configured-vs-effective transport for the init log line:
/// <c>Native (default)</c> when the key was absent and the compiled default applies,
/// <c>Libplctag (node default)</c> when the key was absent and the node pinned a different default,
/// <c>Native (configured)</c> when the key was explicit.
/// </summary>
/// <param name="configured">The configured transport, or null.</param>
/// <param name="nodeDefault">The node-level default.</param>
/// <returns>A short description.</returns>
public static string Describe(AbCipTransport? configured, AbCipTransport nodeDefault = Default) =>
configured is not null ? $"{configured} (configured)"
: nodeDefault == Default ? $"{nodeDefault} (default)"
: $"{nodeDefault} (node default)";
}
@@ -25,10 +25,10 @@ public interface IAbCipTagRuntime : IDisposable
Task WriteAsync(CancellationToken cancellationToken);
/// <summary>
/// Raw libplctag status code — mapped to an OPC UA StatusCode via
/// <see cref="AbCipStatusMapper.MapLibplctagStatus(int)"/>. Zero on success, negative on error.
/// Raw transport status code (an <c>AbStatus</c> value) — mapped to an OPC UA StatusCode via
/// <see cref="AbCipStatusMapper.MapStatus(int)"/>. Zero on success, negative on error.
/// </summary>
/// <returns>The raw libplctag status code (zero on success, negative on error).</returns>
/// <returns>The <c>AbStatus</c> value (zero on success, negative on error).</returns>
int GetStatus();
/// <summary>
@@ -119,6 +119,13 @@ public interface IAbCipTagFactory
/// element count for an <c>isArray</c> tag — including a 1-element array.</param>
/// <param name="IsArray">Review I-1 — the EXPLICIT array signal threaded from the tag definition so
/// a 1-element array (<see cref="ElementCount"/> 1) is still read as an array, not a scalar.</param>
/// <param name="AuthoredType">The tag definition's authored data type, when known. The native runtime
/// checks the CIP type code the controller returns against it (a REAL authored as DINT is
/// <c>TypeMismatch</c>); libplctag ignores it. Null (enumeration, probes, BOOL-in-DINT parents)
/// skips the check.</param>
/// <param name="UseConnectedMessaging">From <c>AbCipPlcFamilyProfile.SupportsConnectedMessaging</c> at
/// every construction site, so the family profile stays the single authority for the native
/// transport's routing choice. libplctag ignores it.</param>
public sealed record AbCipTagCreateParams(
string Gateway,
int Port,
@@ -129,7 +136,9 @@ public sealed record AbCipTagCreateParams(
bool AllowPacking = true,
int ConnectionSize = 4002,
int ElementCount = 1,
bool IsArray = false)
bool IsArray = false,
AbCipDataType? AuthoredType = null,
bool UseConnectedMessaging = true)
{
/// <summary>
/// The value for libplctag's <c>gateway</c> attribute: <c>host:port</c>, or bare <c>host</c> when
@@ -0,0 +1,143 @@
using System.Collections.Concurrent;
using System.Reflection;
using libplctag;
using libplctag.NativeImport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Releases the native tag a FAILED libplctag initialisation leaves behind (T21 press pass 1 crash).
/// </summary>
/// <remarks>
/// <para>libplctag.NET 1.5.2's <c>NativeTagWrapper.InitializeAsync</c> calls <c>plc_tag_create_ex</c>, which returns a
/// native handle with the wrapper's <c>callback_func_ex</c> delegate registered, and only then waits for the
/// <c>Created</c> event. When that event carries an error (a tag the controller does not have, a refused path, ...)
/// it throws WITHOUT setting <c>_isInitialized</c>, and <c>Dispose()</c> unregisters and destroys only
/// <c>if (_isInitialized)</c>. So the native tag outlives its wrapper with the callback still registered; once the
/// wrapper is collected the delegate's thunk is freed, and the next event libplctag raises on that tag — at the
/// latest the <c>DESTROYED</c> event from <c>plc_tag_shutdown</c>, which the native library runs from
/// <c>atexit</c> — kills the process with "A callback was made on a garbage collected delegate of type
/// 'libplctag.NativeImport!libplctag.NativeImport.plctag+callback_func_ex::Invoke'". A timed-out initialisation
/// unregisters the callback but never destroys the handle either, so that native tag keeps reconnecting in the
/// background for the life of the process.</para>
/// <para>The wrapper exposes no handle, so it is read by reflection from the two private fields of the pinned
/// 1.5.2 wrapper (<c>Tag._tag</c>, <c>NativeTagWrapper.nativeTagHandle</c>). If a future wrapper renames them the
/// release cannot run; the tag is then rooted for the life of the process instead, which keeps its delegate
/// alive. That is no crash but an UNBOUNDED leak — one native tag (holding its libplctag session open) per failed
/// create, and the data path re-creates a NotFound tag on every read — so <c>LibplctagOrphanReleaseTests</c> fails, so the bump is noticed.</para>
/// </remarks>
internal static class LibplctagOrphanRelease
{
private const BindingFlags Private = BindingFlags.NonPublic | BindingFlags.Instance;
private static readonly FieldInfo? WrapperField = typeof(Tag).GetField("_tag", Private);
// Final review M2: the field must also still be an int. A wrapper that retyped it (IntPtr, long) would make
// the cast in HandleOf throw; treating it as unreadable instead degrades to the rooting fallback ("keep
// alive"), which the pin test reports.
private static readonly FieldInfo? HandleField =
WrapperField?.FieldType.GetField("nativeTagHandle", Private) is { } f && f.FieldType == typeof(int) ? f : null;
/// <summary>Tags whose handle could not be read: kept reachable so their callback delegate is never collected.</summary>
private static readonly ConcurrentBag<Tag> Rooted = [];
private static int _released;
/// <summary>True when the pinned wrapper's private fields were found, so a failed tag can be destroyed.</summary>
internal static bool CanReadHandles => HandleField is not null && !ForceRootingForTest;
/// <summary>Test seam (review r1 M1): take the rooting fallback as if the wrapper's fields had been renamed.</summary>
internal static bool ForceRootingForTest { get; set; }
/// <summary>Orphaned native tags destroyed so far (process-wide; diagnostics and tests).</summary>
internal static int Released => Volatile.Read(ref _released);
/// <summary>Orphaned tags rooted for the process lifetime because their handle could not be read.</summary>
internal static int RootedCount => Rooted.Count;
/// <summary>The native handle the wrapper holds (0 when none was ever created, or unreadable). Never throws:
/// callers run it inside a catch block, where an exception would replace the failure being handled.</summary>
internal static int HandleOf(Tag tag)
{
try
{
return HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper
&& HandleField.GetValue(wrapper) is int handle ? handle : 0;
}
catch (Exception)
{
return 0;
}
}
/// <summary>Clears the wrapper's handle after a destroy, so a repeat release is a no-op (review r1 M2).</summary>
private static void ClearHandle(Tag tag)
{
if (HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper) HandleField.SetValue(wrapper, 0);
}
/// <summary>
/// Runs a libplctag operation that may initialise <paramref name="tag"/> (InitializeAsync, or a first
/// Read/Write, which initialise on demand). If it fails and leaves the tag uninitialised, the native tag it
/// created is unregistered and destroyed before the failure propagates.
/// </summary>
internal static async Task RunAsync(Tag tag, Func<Task> op)
{
try
{
await op().ConfigureAwait(false);
}
catch
{
ReleaseIfOrphaned(tag);
throw;
}
}
/// <inheritdoc cref="RunAsync(Tag, Func{Task})"/>
internal static async Task<T> RunAsync<T>(Tag tag, Func<Task<T>> op)
{
try
{
return await op().ConfigureAwait(false);
}
catch
{
ReleaseIfOrphaned(tag);
throw;
}
}
/// <summary>
/// Destroys the native tag behind an UNINITIALISED wrapper, unregistering its callback first. No-op for an
/// initialised tag (its own <c>Dispose</c> handles it) or one that never got a handle. Never throws.
/// </summary>
/// <returns>The status the native tag held just before it was destroyed (what <c>GetStatus()</c> would have
/// reported), or null when nothing was destroyed.</returns>
internal static Status? ReleaseIfOrphaned(Tag tag)
{
try
{
if (tag.IsInitialized) return null;
if (!CanReadHandles)
{
Rooted.Add(tag);
return null;
}
var handle = HandleOf(tag);
if (handle <= 0) return null; // plc_tag_create_ex refused synchronously: nothing native exists
var status = (Status)plctag.plc_tag_status(handle);
plctag.plc_tag_unregister_callback(handle);
plctag.plc_tag_destroy(handle);
ClearHandle(tag);
Interlocked.Increment(ref _released);
return status;
}
catch (Exception)
{
// A release must never mask the initialisation failure it follows. Root the tag so that, whatever
// state the native side is in, its callback delegate stays alive.
Rooted.Add(tag);
return null;
}
}
}
@@ -0,0 +1,13 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Normalizes a parsed CIP route for libplctag's <c>Path</c> attribute. libplctag rejects an
/// empty-string path with <c>ErrorBadData</c>; "no route" (Micro800, unrouted MicroLogix) must be
/// expressed by omitting the attribute, i.e. <see langword="null"/>.
/// </summary>
internal static class LibplctagPath
{
/// <summary>Returns <see langword="null"/> for a null, empty or whitespace path; otherwise the path unchanged.</summary>
/// <param name="path">The parsed CIP path.</param>
public static string? Normalize(string? path) => string.IsNullOrWhiteSpace(path) ? null : path;
}
@@ -0,0 +1,76 @@
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>The ONLY place libplctag's <see cref="Status"/> meets <see cref="AbStatus"/>.</summary>
internal static class LibplctagStatusTranslation
{
/// <summary>Translates a libplctag status into the driver-owned <see cref="AbStatus"/>.</summary>
/// <param name="s">The libplctag status.</param>
/// <returns>The equivalent transport-neutral status.</returns>
public static AbStatus ToAbStatus(Status s) => s switch
{
Status.Ok => AbStatus.Ok,
Status.Pending => AbStatus.Pending,
Status.ErrorTimeout => AbStatus.Timeout,
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.NotFound,
Status.ErrorNotAllowed => AbStatus.NotAllowed,
Status.ErrorOutOfBounds => AbStatus.OutOfBounds,
Status.ErrorTooLarge => AbStatus.TooLarge,
Status.ErrorTooSmall => AbStatus.TooSmall,
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbStatus.Unsupported,
Status.ErrorPartial => AbStatus.Partial,
Status.ErrorAbort => AbStatus.Abort,
Status.ErrorBadReply => AbStatus.BadReply,
_ => AbStatus.BadConnection,
};
/// <summary>
/// Recovers the status a <see cref="LibPlcTagException"/> was thrown on. libplctag builds the
/// exception from <c>Status.ToString()</c>, so the message is the enum name.
/// </summary>
/// <param name="ex">The exception.</param>
/// <returns>The translated status, or <see cref="AbStatus.BadConnection"/> when the message names no status.</returns>
public static AbStatus ToAbStatus(LibPlcTagException ex) =>
Enum.TryParse<Status>((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out var s) && Enum.IsDefined(s)
? ToAbStatus(s)
: AbStatus.BadConnection;
/// <summary>Runs <paramref name="op"/>, rethrowing any <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
/// <param name="op">The libplctag operation.</param>
/// <returns>A task completing when the operation does.</returns>
public static async Task Wrap(Func<Task> op)
{
try { await op().ConfigureAwait(false); }
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
}
/// <summary>Runs <paramref name="op"/>, rethrowing any <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
/// <typeparam name="T">The result type.</typeparam>
/// <param name="op">The libplctag operation.</param>
/// <returns>The operation result.</returns>
public static async Task<T> Wrap<T>(Func<Task<T>> op)
{
try { return await op().ConfigureAwait(false); }
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
}
/// <summary>Runs a synchronous libplctag call, rethrowing <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
/// <typeparam name="T">The result type.</typeparam>
/// <param name="op">The libplctag call.</param>
/// <returns>The call result.</returns>
public static T WrapSync<T>(Func<T> op)
{
try { return op(); }
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
}
/// <summary>Runs a synchronous libplctag call, rethrowing <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
/// <param name="op">The libplctag call.</param>
public static void WrapSync(Action op)
{
try { op(); }
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
}
}
@@ -1,85 +1,24 @@
using System.Runtime.CompilerServices;
using libplctag;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Real <see cref="IAbCipTagEnumerator"/> that walks a Logix controller's symbol table by
/// reading the <c>@tags</c> pseudo-tag via libplctag + decoding the CIP Symbol Object
/// response with <see cref="CipSymbolObjectDecoder"/>.
/// libplctag listing reader: reads a <c>@tags</c> / <c>Program:X.@tags</c> pseudo-tag and returns
/// its raw Symbol Object bytes for <see cref="SymbolListingEnumerator"/>.
/// </summary>
/// <remarks>
/// <para>libplctag's <c>Tag.GetBuffer()</c> returns the raw Symbol Object bytes when the
/// tag name is <c>@tags</c>. The decoder walks the concatenated entries + emits
/// <see cref="AbCipDiscoveredTag"/> records matching our driver surface.</para>
///
/// <para><see cref="EmptyAbCipTagEnumerator"/> remains available for tests that don't want
/// to touch the native library; the production factory default
/// (<see cref="LibplctagTagEnumeratorFactory"/>) wires this implementation in.</para>
/// </remarks>
internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
internal static class LibplctagListingReader
{
private readonly Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> _readListing;
/// <summary>Production enumerator: reads each listing through libplctag.</summary>
public LibplctagTagEnumerator() : this(ReadListingAsync) { }
/// <summary>Enumerator over an injected listing reader (unit-test seam).</summary>
/// <param name="readListing">Reads one symbol listing (<c>@tags</c> or <c>Program:X.@tags</c>) and
/// returns its raw Symbol Object bytes.</param>
internal LibplctagTagEnumerator(Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> readListing)
=> _readListing = readListing;
/// <inheritdoc />
/// <remarks>
/// A controller's <c>@tags</c> listing holds controller-scope tags plus one system entry per
/// program (<c>Program:UN01_Press</c>); the program's own tags are listed only by
/// <c>Program:&lt;name&gt;.@tags</c>. So the controller listing is read first, then each program's
/// listing, decoded with that program as the scope. A program whose listing cannot be read is
/// skipped — browse is best-effort and one unreadable program must not cost the others.
/// </remarks>
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
AbCipTagCreateParams deviceParams,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var buffer = await _readListing(deviceParams, "@tags", cancellationToken).ConfigureAwait(false);
// The decoder already surfaces each struct tag's CIP template instance id on
// AbCipDiscoveredTag.TemplateInstanceId (from the lower 12 bits of the symbol type), so
// re-yielding the decoded records as-is carries it through to DiscoverAsync's UDT fan-out.
var programs = new List<string>();
foreach (var tag in CipSymbolObjectDecoder.Decode(buffer))
{
if (CipSymbolObjectDecoder.ProgramNameOf(tag) is { } program) programs.Add(program);
yield return tag;
}
foreach (var program in programs)
{
byte[] programBuffer;
try
{
programBuffer = await _readListing(deviceParams, $"Program:{program}.@tags", cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) { throw; }
catch (Exception)
{
continue;
}
foreach (var tag in CipSymbolObjectDecoder.Decode(programBuffer, programScope: program))
yield return tag;
}
}
private static async Task<byte[]> ReadListingAsync(
internal static async Task<byte[]> ReadAsync(
AbCipTagCreateParams deviceParams, string listingName, CancellationToken cancellationToken)
{
using var tag = BuildSymbolListTag(deviceParams, listingName);
await tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await tag.ReadAsync(cancellationToken).ConfigureAwait(false);
return tag.GetBuffer();
// A failed initialisation would otherwise leave the native tag (and its callback) behind: see LibplctagOrphanRelease.
return await LibplctagOrphanRelease.RunAsync(tag, () => LibplctagStatusTranslation.Wrap(async () =>
{
await tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await tag.ReadAsync(cancellationToken).ConfigureAwait(false);
return tag.GetBuffer();
})).ConfigureAwait(false);
}
/// <summary>
@@ -99,16 +38,13 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
internal static Tag BuildSymbolListTag(AbCipTagCreateParams deviceParams, string listingName = "@tags") => new()
{
Gateway = deviceParams.LibplctagGateway,
Path = deviceParams.CipPath,
Path = LibplctagPath.Normalize(deviceParams.CipPath),
PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute),
Protocol = Protocol.ab_eip,
Name = listingName,
Timeout = deviceParams.Timeout,
};
/// <summary>Nothing to release — each listing's libplctag tag is disposed after its read.</summary>
public void Dispose() { }
private static PlcType MapPlcType(string attribute) => attribute switch
{
"controllogix" => PlcType.ControlLogix,
@@ -118,9 +54,9 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
};
}
/// <summary>Factory for <see cref="LibplctagTagEnumerator"/>.</summary>
/// <summary>Factory for the libplctag-backed <see cref="SymbolListingEnumerator"/>.</summary>
internal sealed class LibplctagTagEnumeratorFactory : IAbCipTagEnumeratorFactory
{
/// <inheritdoc />
public IAbCipTagEnumerator Create() => new LibplctagTagEnumerator();
public IAbCipTagEnumerator Create() => new SymbolListingEnumerator(LibplctagListingReader.ReadAsync);
}
@@ -1,4 +1,5 @@
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
@@ -13,16 +14,31 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
{
private readonly Tag _tag;
/// <summary>
/// The status the native tag held when a failed initialisation destroyed it (see
/// <see cref="LibplctagOrphanRelease"/>); null while the wrapper's own handle is authoritative.
/// </summary>
private Status? _releasedStatus;
/// <summary>The native handle a failed initialisation destroyed (0 if none); the wrapper's own field is cleared.</summary>
private int _releasedHandle;
/// <summary>Initializes a new instance of the LibplctagTagRuntime class.</summary>
/// <param name="p">The tag creation parameters.</param>
public LibplctagTagRuntime(AbCipTagCreateParams p)
{
_tag = new Tag
_tag = BuildTag(p);
}
/// <summary>Builds the (uninitialized) libplctag tag for <paramref name="p"/>.</summary>
/// <param name="p">The tag creation parameters.</param>
internal static Tag BuildTag(AbCipTagCreateParams p) =>
new Tag
{
// host:port — see AbLegacyTagCreateParams/AbCipTagCreateParams.LibplctagGateway.
// Passing p.Gateway alone drops the authored port onto libplctag's 44818 default (#534).
Gateway = p.LibplctagGateway,
Path = p.CipPath,
Path = LibplctagPath.Normalize(p.CipPath),
PlcType = MapPlcType(p.LibplctagPlcAttribute),
Protocol = Protocol.ab_eip,
Name = p.TagName,
@@ -32,28 +48,73 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
// count so libplctag pulls every element in one CIP read; scalar tags pass 1.
ElementCount = p.ElementCount > 1 ? p.ElementCount : 1,
};
/// <inheritdoc />
public Task InitializeAsync(CancellationToken cancellationToken) =>
RunReleasingOrphanAsync(() => _tag.InitializeAsync(cancellationToken));
/// <inheritdoc />
/// <remarks>libplctag initialises an uninitialised tag on its first read, so this path can orphan one too.</remarks>
public Task ReadAsync(CancellationToken cancellationToken) =>
RunReleasingOrphanAsync(() => _tag.ReadAsync(cancellationToken));
/// <inheritdoc />
public Task WriteAsync(CancellationToken cancellationToken) =>
RunReleasingOrphanAsync(() => _tag.WriteAsync(cancellationToken));
/// <summary>
/// Runs a libplctag operation; when it fails and leaves the tag uninitialised, destroys the native tag it
/// created before the failure propagates, so its callback can never fire into a collected delegate (T21 press
/// pass 1 crash). The status that tag held is kept for <see cref="GetStatus"/>.
/// </summary>
private async Task RunReleasingOrphanAsync(Func<Task> op)
{
try
{
await LibplctagStatusTranslation.Wrap(op).ConfigureAwait(false);
_releasedStatus = null;
_releasedHandle = 0;
}
catch
{
var handle = LibplctagOrphanRelease.HandleOf(_tag);
if (LibplctagOrphanRelease.ReleaseIfOrphaned(_tag) is { } status)
{
_releasedStatus = status;
_releasedHandle = handle;
}
throw;
}
}
/// <inheritdoc />
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
/// <summary>The native libplctag handle this runtime's tag holds, or the one a failed initialisation destroyed
/// (0 if none was created). Tests only.</summary>
internal int NativeHandle => _releasedHandle != 0 ? _releasedHandle : LibplctagOrphanRelease.HandleOf(_tag);
/// <summary>The handle the libplctag wrapper itself still holds (0 once a release cleared it). Tests only.</summary>
internal int WrapperHandle => LibplctagOrphanRelease.HandleOf(_tag);
/// <inheritdoc />
public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken);
public int GetStatus() =>
(int)LibplctagStatusTranslation.ToAbStatus(_releasedStatus ?? _tag.GetStatus());
/// <inheritdoc />
public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken);
public int GetSize() => LibplctagStatusTranslation.WrapSync(() => _tag.GetSize());
/// <inheritdoc />
public int GetStatus() => (int)_tag.GetStatus();
/// <inheritdoc />
public int GetSize() => _tag.GetSize();
/// <summary>
/// A copy of the bytes of the last read, for the read-only differential harness (Task 18) to compare
/// against the native runtime byte for byte. Not part of <see cref="IAbCipTagRuntime"/>.
/// </summary>
internal ReadOnlyMemory<byte> RawBuffer => LibplctagStatusTranslation.WrapSync(() => _tag.GetBuffer());
/// <inheritdoc />
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
/// <inheritdoc />
public object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex) => type switch
public object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex) =>
LibplctagStatusTranslation.WrapSync(() => DecodeValueAtCore(type, offset, bitIndex));
private object? DecodeValueAtCore(AbCipDataType type, int offset, int? bitIndex) => type switch
{
AbCipDataType.Bool => bitIndex is int bit
? _tag.GetBit(bit)
@@ -75,7 +136,10 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
};
/// <inheritdoc />
public object? DecodeArray(AbCipDataType type, int count)
public object? DecodeArray(AbCipDataType type, int count) =>
LibplctagStatusTranslation.WrapSync(() => DecodeArrayCore(type, count));
private object? DecodeArrayCore(AbCipDataType type, int count)
{
if (count < 1) count = 1;
// libplctag reports the per-element byte size once the tag has been read; it correctly
@@ -156,7 +220,10 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
};
/// <inheritdoc />
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value) =>
LibplctagStatusTranslation.WrapSync(() => EncodeValueCore(type, bitIndex, value));
private void EncodeValueCore(AbCipDataType type, int? bitIndex, object? value)
{
switch (type)
{
@@ -20,9 +20,14 @@ internal sealed class LibplctagTemplateReader : IAbCipTemplateReader
{
_tag?.Dispose();
_tag = BuildTemplateTag(deviceParams, templateInstanceId);
await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await _tag.ReadAsync(cancellationToken).ConfigureAwait(false);
return _tag.GetBuffer();
var tag = _tag;
// A failed initialisation would otherwise leave the native tag (and its callback) behind: see LibplctagOrphanRelease.
return await LibplctagOrphanRelease.RunAsync(tag, () => LibplctagStatusTranslation.Wrap(async () =>
{
await tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
await tag.ReadAsync(cancellationToken).ConfigureAwait(false);
return tag.GetBuffer();
})).ConfigureAwait(false);
}
/// <summary>Builds the (uninitialized) libplctag tag for the <c>@udt/{id}</c> pseudo.</summary>
@@ -35,7 +40,7 @@ internal sealed class LibplctagTemplateReader : IAbCipTemplateReader
internal static Tag BuildTemplateTag(AbCipTagCreateParams deviceParams, uint templateInstanceId) => new()
{
Gateway = deviceParams.LibplctagGateway,
Path = deviceParams.CipPath,
Path = LibplctagPath.Normalize(deviceParams.CipPath),
PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute),
Protocol = Protocol.ab_eip,
Name = $"@udt/{templateInstanceId}",
@@ -0,0 +1,55 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Maps <see cref="AbCipTagCreateParams"/> onto the native transport's connection identity and
/// settings. Shared by the native tag runtime, enumerator and template reader so every handle on
/// one device leases the same pooled connection.
/// </summary>
internal static class NativeAbCipDevice
{
/// <summary>The EtherNet/IP default TCP port, used when no usable port was configured.</summary>
internal const int DefaultPort = 44818;
/// <summary>The <c>plc=</c> attribute of the Micro800 family profile.</summary>
private const string Micro800Attribute = "micro800";
/// <summary>The pool key: one CIP connection per <c>(host, port, cipPath, plcAttribute)</c>.</summary>
/// <param name="p">The tag creation parameters.</param>
/// <returns>The device key.</returns>
public static DeviceKey Key(AbCipTagCreateParams p) =>
new(p.Gateway, p.Port > 0 ? p.Port : DefaultPort, p.CipPath, p.LibplctagPlcAttribute);
/// <summary>The connection settings for the device <paramref name="p"/> addresses.</summary>
/// <param name="p">The tag creation parameters.</param>
/// <returns>The connection options.</returns>
public static ConnectionOptions Options(AbCipTagCreateParams p) => new()
{
Routing = RoutingFor(p),
Timeout = p.Timeout,
ConnectionSize = p.ConnectionSize,
AllowPacking = p.AllowPacking,
};
/// <summary>
/// How requests reach the controller's message router.
/// </summary>
/// <remarks>
/// Micro800 is <b>always</b> <see cref="CipRouting.Connected"/> with an empty route, even though its
/// family profile says <c>SupportsConnectedMessaging: false</c>: libplctag opens a Large Forward
/// Open with connection path <c>20 02 24 01</c> and reads connected (Captures/micro800-read-dint.hex,
/// Captures/README.md "Routing observed"; ruling R12-1 — the capture beats the profile flag).
/// Every other family follows the profile: connected-capable ⇒ Forward Open; otherwise
/// Unconnected Send when a route is configured, bare SendRRData when it is not.
/// </remarks>
/// <param name="p">The tag creation parameters.</param>
/// <returns>The routing mode.</returns>
internal static CipRouting RoutingFor(AbCipTagCreateParams p)
{
if (string.Equals(p.LibplctagPlcAttribute, Micro800Attribute, StringComparison.OrdinalIgnoreCase))
return CipRouting.Connected;
if (p.UseConnectedMessaging) return CipRouting.Connected;
return string.IsNullOrWhiteSpace(p.CipPath) ? CipRouting.Direct : CipRouting.UnconnectedSend;
}
}
@@ -0,0 +1,142 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Native <see cref="IAbCipTagEnumerator"/>: one <see cref="SymbolListingEnumerator"/> whose listing
/// reader pages the Symbol Object Get Instance Attribute List service. The whole enumeration (the
/// controller listing plus every program listing) runs on one leased device connection, sent through
/// the lease's shared scheduler, and the lease is released when the enumeration ends.
/// </summary>
internal sealed class NativeAbCipTagEnumerator : IAbCipTagEnumerator
{
private readonly Func<AbCipTagCreateParams, (RequestScheduler Scheduler, Func<ValueTask> Release)> _acquire;
private readonly ILogger _logger;
/// <summary>Production constructor: leases from <paramref name="pool"/>.</summary>
/// <param name="pool">The connection pool (the factory passes <see cref="DeviceSessionPool.Shared"/>).</param>
public NativeAbCipTagEnumerator(DeviceSessionPool pool)
: this(p =>
{
var lease = pool.Acquire(NativeAbCipDevice.Key(p), NativeAbCipDevice.Options(p));
return (lease.Scheduler, lease.DisposeAsync);
})
{
}
/// <summary>Test seam: supplies the scheduler and its release directly.</summary>
/// <param name="acquire">Acquires the scheduler for a device, with the callback that releases it.</param>
/// <param name="logger">Receives release failures; null discards them.</param>
internal NativeAbCipTagEnumerator(
Func<AbCipTagCreateParams, (RequestScheduler Scheduler, Func<ValueTask> Release)> acquire,
ILogger? logger = null)
{
_acquire = acquire;
_logger = logger ?? NullLogger.Instance;
}
/// <inheritdoc />
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
AbCipTagCreateParams deviceParams,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var (scheduler, release) = _acquire(deviceParams);
try
{
using var inner = new SymbolListingEnumerator(
(_, name, ct) => NativeListingReader.ReadAsync(scheduler, name, ct));
await foreach (var tag in inner.EnumerateAsync(deviceParams, cancellationToken).ConfigureAwait(false))
yield return tag;
}
finally
{
NativeLease.ReleaseBounded(release, _logger);
}
}
/// <summary>Nothing to release — each enumeration owns and releases its own lease.</summary>
public void Dispose() { }
}
/// <summary>Pages a symbol listing off the controller into the concatenated Symbol Object entry bytes.</summary>
internal static class NativeListingReader
{
private const string TagsSuffix = ".@tags";
private const string ProgramPrefix = "Program:";
/// <summary>Page cap for one listing; a real controller needs a small fraction of this.</summary>
internal const int MaxPages = 10_000;
/// <summary>Byte cap for one listing (64 MiB).</summary>
internal const long MaxListingBytes = 64L * 1024 * 1024;
/// <summary>Reads <c>@tags</c> or <c>Program:X.@tags</c> in full.</summary>
/// <param name="scheduler">The device's shared scheduler.</param>
/// <param name="listingName">The listing pseudo-tag name.</param>
/// <param name="cancellationToken">Cancels the read.</param>
/// <returns>The raw entries, byte for byte what <see cref="CipSymbolObjectDecoder"/> expects.</returns>
public static async Task<byte[]> ReadAsync(
RequestScheduler scheduler, string listingName, CancellationToken cancellationToken)
{
string? program = null;
if (listingName != "@tags")
{
if (!listingName.StartsWith(ProgramPrefix, StringComparison.Ordinal)
|| !listingName.EndsWith(TagsSuffix, StringComparison.Ordinal))
throw new ArgumentException($"Unsupported symbol listing '{listingName}'.", nameof(listingName));
program = listingName[ProgramPrefix.Length..^TagsSuffix.Length];
}
using var all = new MemoryStream();
uint start = 0;
for (var page = 0; page < MaxPages; page++)
{
var request = SymbolObjectServices.GetInstanceAttributeList(program, start);
// Exclusive: the reply can fill the connection, and the packer budgets request bytes only.
var reply = await scheduler.SendAsync(request, cancellationToken, exclusive: true).ConfigureAwait(false);
var (entries, last, more) = SymbolObjectServices.ParseListingReply(reply);
all.Write(entries);
if (all.Length > MaxListingBytes)
throw new AbTransportException(AbStatus.BadReply,
$"Symbol listing '{listingName}' exceeded {MaxListingBytes} bytes.");
if (!more) return all.ToArray();
// Instances ascend; a page that does not advance past its start, or a last id that would
// wrap, is a misbehaving reply and would otherwise loop forever against the controller.
if (last < start || last == uint.MaxValue)
throw new AbTransportException(AbStatus.BadReply,
$"Symbol listing '{listingName}' made no progress (start {start}, last {last}).");
start = last + 1;
}
throw new AbTransportException(AbStatus.BadReply,
$"Symbol listing '{listingName}' exceeded {MaxPages} pages.");
}
}
/// <summary>Factory for <see cref="NativeAbCipTagEnumerator"/>.</summary>
internal sealed class NativeAbCipTagEnumeratorFactory : IAbCipTagEnumeratorFactory
{
/// <inheritdoc />
public IAbCipTagEnumerator Create() => new NativeAbCipTagEnumerator(DeviceSessionPool.Shared);
}
/// <summary>Bounded lease release shared by the native enumerator and template reader.</summary>
internal static class NativeLease
{
/// <summary>Upper bound on waiting for the lease release (and its Forward Close).</summary>
private static readonly TimeSpan DisposeWait = TimeSpan.FromSeconds(2);
/// <summary>Releases, waiting at most 2 s; never throws, whatever the release delegate does.</summary>
public static void ReleaseBounded(Func<ValueTask> release, ILogger logger)
{
try { release().AsTask().Wait(DisposeWait); }
catch (Exception ex)
{
logger.LogWarning(ex, "Releasing the native AbCip device lease failed.");
}
}
}
@@ -0,0 +1,18 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// <see cref="IAbCipTagFactory"/> for the native transport: every runtime leases its device's
/// 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(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, _pool);
}
@@ -0,0 +1,504 @@
using System.Buffers.Binary;
using System.Text;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Native <see cref="IAbCipTagRuntime"/> over the managed EtherNet/IP + CIP stack. Leases the
/// device's shared connection from <see cref="DeviceSessionPool.Shared"/> and reads/writes through a
/// per-runtime <see cref="LogixTagClient"/> over the lease's <b>shared</b> scheduler, so concurrent
/// tags on one device coalesce into Multiple Service Packets (review B1). Decoding mirrors
/// <see cref="LibplctagTagRuntime"/> byte for byte; unlike libplctag it also checks the CIP type
/// code the controller returned against the authored type.
/// </summary>
/// <remarks>
/// Not thread-safe: the driver serialises Read → GetStatus → Decode (and Encode → Write → GetStatus)
/// per runtime under its per-runtime lock (05/STAB-4), exactly as it does for libplctag.
/// </remarks>
internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
{
/// <summary>Upper bound on waiting for the lease release (and its Forward Close) in <see cref="Dispose"/>.</summary>
private static readonly TimeSpan DisposeWait = TimeSpan.FromSeconds(2);
/// <summary>Bytes of a Logix STRING's length word.</summary>
private const int StringLengthBytes = 4;
private readonly AbCipTagCreateParams _p;
private readonly DeviceSessionPool? _pool;
private readonly ushort _elements;
// Encoded in InitializeAsync, never in the constructor: a malformed name must surface from
// Initialize (inside the driver's / probe's try), not from IAbCipTagFactory.Create — as libplctag.
private int? _implicitBit;
private byte[]? _path;
private ConnectionLease? _lease;
private RequestScheduler? _privateScheduler; // ForTest only — production always uses the lease's
private LogixTagClient? _client;
private byte[] _data = [];
private ushort? _typeCode;
private ushort? _structHandle;
private AbStatus _status = AbStatus.Pending;
private byte[]? _writeBuffer;
private string? _pendingString;
private bool _disposed;
/// <summary>Production constructor; <see cref="InitializeAsync"/> leases the connection from <paramref name="pool"/>.</summary>
/// <param name="p">The tag creation parameters.</param>
/// <param name="pool">The connection pool (the driver passes <see cref="DeviceSessionPool.Shared"/>).</param>
internal NativeAbCipTagRuntime(AbCipTagCreateParams p, DeviceSessionPool pool)
: this(p)
{
_pool = pool ?? throw new ArgumentNullException(nameof(pool));
}
private NativeAbCipTagRuntime(AbCipTagCreateParams p)
{
_p = p ?? throw new ArgumentNullException(nameof(p));
_elements = (ushort)Math.Clamp(p.ElementCount, 1, ushort.MaxValue);
}
/// <summary>
/// Test seam: a runtime over <paramref name="channel"/> with a private scheduler and no pool
/// lease. Production code always goes through <see cref="NativeAbCipTagFactory"/>.
/// </summary>
/// <param name="channel">The channel to send on.</param>
/// <param name="p">The tag creation parameters.</param>
/// <returns>The runtime.</returns>
internal static NativeAbCipTagRuntime ForTest(ICipChannel channel, AbCipTagCreateParams p)
{
ArgumentNullException.ThrowIfNull(channel);
var rt = new NativeAbCipTagRuntime(p);
rt._privateScheduler = new RequestScheduler(channel, p.AllowPacking);
rt._client = new LogixTagClient(rt._privateScheduler, channel);
return rt;
}
/// <summary>The CIP type code of the last successful read, if any.</summary>
internal ushort? TypeCode => _typeCode;
/// <summary>The structure handle of the last successful read, if the controller returned one.</summary>
internal ushort? StructHandle => _structHandle;
/// <summary>The trailing bit selector split off the tag name (<c>Tag.5</c>), if any.</summary>
internal int? ImplicitBit => _implicitBit;
/// <inheritdoc />
/// <remarks>
/// Performs one read to prove the tag exists: libplctag's <c>InitializeAsync</c> errors for an
/// unknown tag, and the driver's 05/STAB-8 backoff classification depends on create throwing
/// <see cref="AbStatus.NotFound"/>.
/// </remarks>
public async Task InitializeAsync(CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_path is null)
{
// Before any lease: a malformed name costs no connection.
var (name, bit) = CipPath.SplitBitSelector(_p.TagName);
var path = CipPath.EncodeSymbolic(name);
_implicitBit = bit;
_path = path;
}
if (_client is null)
{
var lease = _pool!.Acquire(NativeAbCipDevice.Key(_p), NativeAbCipDevice.Options(_p));
_lease = lease;
// The lease's shared scheduler — never a private one (B1: MSP coalescing across tags).
_client = new LogixTagClient(lease.Scheduler, lease.Channel);
}
await ReadAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task ReadAsync(CancellationToken cancellationToken)
{
var client = Client();
var path = Path();
try
{
var (type, handle, data) = await client.ReadAsync(path,_elements, cancellationToken).ConfigureAwait(false);
_data = data;
_typeCode = type;
_structHandle = handle;
_status = IsTypeMismatch(type) ? AbStatus.TypeMismatch : AbStatus.Ok;
}
catch (AbTransportException ex)
{
// libplctag reports a failed operation by exception AND leaves the status readable.
_status = ex.Status;
throw;
}
}
/// <inheritdoc />
public async Task WriteAsync(CancellationToken cancellationToken)
{
var client = Client();
var path = Path();
// A bit tag ("Flags.3") names one bit of its host word. Writing any encoded value to it would
// write the WHOLE host and wipe its other bits on the controller; BOOL bit writes are an RMW the
// driver performs on a parent-DINT runtime (WriteBitInDIntAsync), so anything reaching here is refused.
if (_implicitBit is int implicitBit)
throw new NotSupportedException(
$"Write to bit tag '{_p.TagName}' (bit {implicitBit}) is not supported here — bit writes go through AbCipDriver.WriteBitInDIntAsync.");
try
{
if (_pendingString is { } text)
{
// A STRING writes as the type the controller reported — a Logix STRING is a structure
// (0x02A0 + handle); ab_server reports 0x00D0 with no handle. Learn it by reading first
// when it is not yet known (we run under the driver's per-runtime lock).
if (_typeCode is null || (_typeCode == LogixTypeCode.Struct && _structHandle is null))
await ReadAsync(cancellationToken).ConfigureAwait(false);
if (_typeCode == LogixTypeCode.Struct && _structHandle is null)
throw new AbTransportException(AbStatus.Unsupported,
$"STRING write to '{_p.TagName}': the controller returned no structure handle.");
var payload = EncodeStringPayload(text);
await client.WriteAsync(path, _typeCode!.Value, _structHandle, 1, payload, cancellationToken)
.ConfigureAwait(false);
}
else if (_writeBuffer is { } buffer)
{
var type = _typeCode ?? AtomicCode(_p.AuthoredType)
?? throw new AbTransportException(AbStatus.Unsupported,
$"Write to '{_p.TagName}': type unknown — no prior read and no authored atomic type.");
// The read rule decides first: a same-width mismatch (REAL under a DINT-authored tag)
// would otherwise send the int bit pattern as a REAL and report Good.
if (_typeCode is { } returned && IsTypeMismatch(returned))
throw new AbTransportException(AbStatus.TypeMismatch,
$"Write to '{_p.TagName}': the controller's type 0x{returned:X4} does not match the authored {_p.AuthoredType}.");
// Width is checked only for codes with a known width: DATE / DATE_AND_TIME (0xCD/0xCF) and
// Micro800 BYTE/WORD/LWORD (0xD1/0xD2/0xD4) are outside LogixTypeCode's table and write as today.
var width = LogixTypeCode.ByteWidth(type);
if (width > 0 && width != buffer.Length)
{
// An authored BOOL over a packed BOOL-array DWORD: writing the DWORD would clobber the
// 31 sibling bools (libplctag does exactly that), so refuse it as a config error.
if (type == LogixTypeCode.BoolArray)
throw new NotSupportedException(
$"Write to '{_p.TagName}': a BOOL-array element (type 0xD3 DWORD) cannot be written as a single BOOL.");
throw new AbTransportException(AbStatus.TypeMismatch,
$"Write to '{_p.TagName}': encoded {buffer.Length} byte(s) but the controller's type 0x{type:X4} is {width} byte(s) wide.");
}
await client.WriteAsync(path, type, null, 1, buffer, cancellationToken).ConfigureAwait(false);
}
else
{
throw new InvalidOperationException("WriteAsync called before EncodeValue.");
}
_status = AbStatus.Ok;
}
catch (AbTransportException ex)
{
_status = ex.Status;
throw;
}
}
/// <inheritdoc />
public int GetStatus() => (int)_status;
/// <inheritdoc />
public int GetSize() => _data.Length;
/// <summary>
/// The bytes of the last read, for the read-only differential harness (Task 18) to compare against
/// libplctag byte for byte. Not part of <see cref="IAbCipTagRuntime"/>.
/// </summary>
internal ReadOnlyMemory<byte> RawBuffer => _data;
/// <inheritdoc />
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
/// <inheritdoc />
public object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex) =>
_status == AbStatus.TypeMismatch ? null : DecodeAt(type, offset, bitIndex);
private object? DecodeAt(AbCipDataType type, int offset, int? bitIndex)
{
// libplctag on a bit tag ("Tag.5"): every integer getter returns that bit, and GetBit ignores
// the index it is given in favour of the tag's own bit.
if (_implicitBit is int implicitBit && IsIntegerShaped(type))
return BoxBit(type, GetBit(implicitBit));
return type switch
{
AbCipDataType.Bool => bitIndex is int bit ? GetBit(bit) : Span(offset, 1)[0] != 0,
AbCipDataType.SInt => (int)(sbyte)Span(offset, 1)[0],
AbCipDataType.USInt => (int)Span(offset, 1)[0],
AbCipDataType.Int => (int)BinaryPrimitives.ReadInt16LittleEndian(Span(offset, 2)),
AbCipDataType.UInt => (int)BinaryPrimitives.ReadUInt16LittleEndian(Span(offset, 2)),
AbCipDataType.DInt or AbCipDataType.Dt => BinaryPrimitives.ReadInt32LittleEndian(Span(offset, 4)),
AbCipDataType.UDInt => BinaryPrimitives.ReadUInt32LittleEndian(Span(offset, 4)),
AbCipDataType.LInt => BinaryPrimitives.ReadInt64LittleEndian(Span(offset, 8)),
AbCipDataType.ULInt => BinaryPrimitives.ReadUInt64LittleEndian(Span(offset, 8)),
AbCipDataType.Real => BinaryPrimitives.ReadSingleLittleEndian(Span(offset, 4)),
AbCipDataType.LReal => BinaryPrimitives.ReadDoubleLittleEndian(Span(offset, 8)),
AbCipDataType.String => DecodeString(offset),
_ => null,
};
}
/// <inheritdoc />
public object? DecodeArray(AbCipDataType type, int count)
{
if (_status == AbStatus.TypeMismatch) return null;
if (count < 1) count = 1;
// Same stride rule as libplctag's runtime (which never has ElementSize after a read).
var stride = LibplctagTagRuntime.ResolveArrayStride(null, _data.Length, count, type);
return type switch
{
// A Logix BOOL array is bit-packed: element i is bit i of the buffer.
AbCipDataType.Bool => BuildBoolArray(count),
AbCipDataType.SInt or AbCipDataType.USInt or AbCipDataType.Int or AbCipDataType.UInt
or AbCipDataType.DInt or AbCipDataType.Dt => BuildArray<int>(count, stride, type),
AbCipDataType.UDInt => BuildArray<uint>(count, stride, type),
AbCipDataType.LInt => BuildArray<long>(count, stride, type),
AbCipDataType.ULInt => BuildArray<ulong>(count, stride, type),
AbCipDataType.Real => BuildArray<float>(count, stride, type),
AbCipDataType.LReal => BuildArray<double>(count, stride, type),
AbCipDataType.String => BuildArray<string>(count, stride, type),
_ => null,
};
}
/// <inheritdoc />
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
{
_writeBuffer = null;
_pendingString = null;
switch (type)
{
case AbCipDataType.Bool:
if (bitIndex is int)
{
// Same contract as LibplctagTagRuntime: BOOL-within-DINT writes are an RMW the driver
// performs on a parent-DINT runtime (AbCipDriver.WriteBitInDIntAsync).
throw new NotSupportedException(
"BOOL-with-bitIndex writes must go through AbCipDriver.WriteBitInDIntAsync, not NativeAbCipTagRuntime.");
}
_writeBuffer = [Convert.ToBoolean(value) ? (byte)1 : (byte)0];
break;
case AbCipDataType.SInt:
_writeBuffer = [(byte)Convert.ToSByte(value)];
break;
case AbCipDataType.USInt:
_writeBuffer = [Convert.ToByte(value)];
break;
case AbCipDataType.Int:
_writeBuffer = new byte[2];
BinaryPrimitives.WriteInt16LittleEndian(_writeBuffer, Convert.ToInt16(value));
break;
case AbCipDataType.UInt:
_writeBuffer = new byte[2];
BinaryPrimitives.WriteUInt16LittleEndian(_writeBuffer, Convert.ToUInt16(value));
break;
case AbCipDataType.DInt:
case AbCipDataType.Dt:
_writeBuffer = new byte[4];
BinaryPrimitives.WriteInt32LittleEndian(_writeBuffer, Convert.ToInt32(value));
break;
case AbCipDataType.UDInt:
_writeBuffer = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(_writeBuffer, Convert.ToUInt32(value));
break;
case AbCipDataType.LInt:
_writeBuffer = new byte[8];
BinaryPrimitives.WriteInt64LittleEndian(_writeBuffer, Convert.ToInt64(value));
break;
case AbCipDataType.ULInt:
_writeBuffer = new byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(_writeBuffer, Convert.ToUInt64(value));
break;
case AbCipDataType.Real:
_writeBuffer = new byte[4];
BinaryPrimitives.WriteSingleLittleEndian(_writeBuffer, Convert.ToSingle(value));
break;
case AbCipDataType.LReal:
_writeBuffer = new byte[8];
BinaryPrimitives.WriteDoubleLittleEndian(_writeBuffer, Convert.ToDouble(value));
break;
case AbCipDataType.String:
// The payload needs the capacity learned from a read, which WriteAsync may have to do.
_pendingString = Convert.ToString(value) ?? string.Empty;
break;
case AbCipDataType.Structure:
throw new NotSupportedException(
"Whole-UDT writes are not supported — address individual member paths instead.");
default:
throw new NotSupportedException($"AbCipDataType {type} not writable.");
}
}
/// <summary>
/// Releases the connection lease, waiting at most 2 s so an immediate re-init does not open a
/// second session to the device while this one's Forward Close is still in flight (review
/// finding 22). Never disposes the shared scheduler; the pool does that on the last release.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
try
{
if (_lease is { } lease)
lease.DisposeAsync().AsTask().Wait(DisposeWait);
else if (_privateScheduler is { } scheduler)
scheduler.DisposeAsync().AsTask().Wait(DisposeWait);
}
catch (AggregateException)
{
// A faulted close is the pool's problem to log; Dispose must not throw.
}
_lease = null;
_privateScheduler = null;
}
private byte[] Path() =>
_path ?? throw new InvalidOperationException("InitializeAsync has not been called.");
private LogixTagClient Client()
{
ObjectDisposedException.ThrowIf(_disposed, this);
return _client ?? throw new InvalidOperationException("InitializeAsync has not been called.");
}
/// <summary>
/// True when the controller returned an atomic type other than the authored one. Exempt:
/// no authored type, String/Structure, a non-atomic return, a BOOL bit selector against an integer
/// host (<c>Flags.3</c> on a DINT), and an authored BOOL answered as 0xD3 (a packed BOOL-array
/// DWORD, which is how Logix returns a BOOL-array element), and same-width integers that differ only
/// in signedness or are the Micro800 bit-string type of that width (SINT/USINT/BYTE, INT/UINT/WORD,
/// DINT/UDINT/DWORD, LINT/ULINT/LWORD): libplctag reads those Good today and a transport cutover
/// must not turn them Bad. Different widths and integer↔real still mismatch.
/// </summary>
private bool IsTypeMismatch(ushort returned)
{
if (AtomicCode(_p.AuthoredType) is not { } expected) return false;
if (LogixTypeCode.ByteWidth(returned) < 0 || returned == expected) return false;
if (_p.AuthoredType == AbCipDataType.Bool)
{
if (returned == LogixTypeCode.BoolArray) return false;
if (_implicitBit is not null && IsIntegerCode(returned)) return false;
}
if (IntegerWidth(expected) is int width && IntegerWidth(returned) == width) return false;
return true;
}
/// <summary>Micro800 bit-string type codes (DWORD is <see cref="LogixTypeCode.BoolArray"/>, 0xD3).</summary>
private const ushort ByteCode = 0xD1, WordCode = 0xD2, LWordCode = 0xD4;
/// <summary>Width of an integer or bit-string code; null for anything else (BOOL, REAL, ...).</summary>
private static int? IntegerWidth(ushort code) => code switch
{
LogixTypeCode.SInt or LogixTypeCode.USInt or ByteCode => 1,
LogixTypeCode.Int or LogixTypeCode.UInt or WordCode => 2,
LogixTypeCode.DInt or LogixTypeCode.UDInt or LogixTypeCode.BoolArray => 4,
LogixTypeCode.LInt or LogixTypeCode.ULInt or LWordCode => 8,
_ => null,
};
/// <summary>The Logix type code an authored type expects, or null when it is exempt from checking.</summary>
private static ushort? AtomicCode(AbCipDataType? type) => type switch
{
AbCipDataType.Bool => LogixTypeCode.Bool,
AbCipDataType.SInt => LogixTypeCode.SInt,
AbCipDataType.USInt => LogixTypeCode.USInt,
AbCipDataType.Int => LogixTypeCode.Int,
AbCipDataType.UInt => LogixTypeCode.UInt,
AbCipDataType.DInt or AbCipDataType.Dt => LogixTypeCode.DInt,
AbCipDataType.UDInt => LogixTypeCode.UDInt,
AbCipDataType.LInt => LogixTypeCode.LInt,
AbCipDataType.ULInt => LogixTypeCode.ULInt,
AbCipDataType.Real => LogixTypeCode.Real,
AbCipDataType.LReal => LogixTypeCode.LReal,
_ => null, // String, Structure
};
private static bool IsIntegerCode(ushort code) => code is LogixTypeCode.SInt or LogixTypeCode.Int
or LogixTypeCode.DInt or LogixTypeCode.LInt or LogixTypeCode.USInt or LogixTypeCode.UInt
or LogixTypeCode.UDInt or LogixTypeCode.ULInt;
private static bool IsIntegerShaped(AbCipDataType type) => type is AbCipDataType.Bool or AbCipDataType.SInt
or AbCipDataType.USInt or AbCipDataType.Int or AbCipDataType.UInt or AbCipDataType.DInt
or AbCipDataType.UDInt or AbCipDataType.LInt or AbCipDataType.ULInt or AbCipDataType.Dt;
private static object BoxBit(AbCipDataType type, bool bit) => type switch
{
AbCipDataType.Bool => bit,
AbCipDataType.UDInt => bit ? 1u : 0u,
AbCipDataType.LInt => bit ? 1L : 0L,
AbCipDataType.ULInt => bit ? 1UL : 0UL,
_ => bit ? 1 : 0,
};
/// <summary>Bit <paramref name="bit"/> of the little-endian buffer.</summary>
private bool GetBit(int bit)
{
if (bit < 0 || bit / 8 >= _data.Length)
throw new AbTransportException(AbStatus.OutOfBounds,
$"Bit {bit} is outside the {_data.Length}-byte buffer of '{_p.TagName}'.");
return (_data[bit / 8] & (1 << (bit % 8))) != 0;
}
/// <summary>The <paramref name="length"/> bytes at <paramref name="offset"/>; OutOfBounds like libplctag.</summary>
private ReadOnlySpan<byte> Span(int offset, int length)
{
if (offset < 0 || length > _data.Length - offset)
throw new AbTransportException(AbStatus.OutOfBounds,
$"Offset {offset} + {length} byte(s) is outside the {_data.Length}-byte buffer of '{_p.TagName}'.");
return _data.AsSpan(offset, length);
}
/// <summary>Logix STRING: <c>u32 LEN</c> then ASCII DATA, LEN clamped to what the buffer holds.</summary>
private string DecodeString(int offset)
{
var len = BinaryPrimitives.ReadUInt32LittleEndian(Span(offset, StringLengthBytes));
var available = _data.Length - offset - StringLengthBytes;
var count = (int)Math.Min(len, (uint)available);
return Encoding.ASCII.GetString(_data, offset + StringLengthBytes, count);
}
/// <summary>
/// <c>u32 len</c> + DATA padded to the capacity learned from the last read (data length − 4), so the
/// write carries the whole structure as the controller sized it.
/// </summary>
private byte[] EncodeStringPayload(string text)
{
var capacity = _data.Length - StringLengthBytes;
var bytes = Encoding.ASCII.GetBytes(text);
if (capacity < 0 || bytes.Length > capacity)
throw new AbTransportException(AbStatus.TooLarge,
$"STRING write to '{_p.TagName}': {bytes.Length} character(s) exceeds the {Math.Max(capacity, 0)}-byte capacity.");
var payload = new byte[StringLengthBytes + capacity];
BinaryPrimitives.WriteUInt32LittleEndian(payload, (uint)bytes.Length);
bytes.CopyTo(payload, StringLengthBytes);
return payload;
}
private bool[] BuildBoolArray(int count)
{
var arr = new bool[count];
for (var i = 0; i < count; i++) arr[i] = GetBit(i);
return arr;
}
private T[] BuildArray<T>(int count, int stride, AbCipDataType type)
{
var arr = new T[count];
for (var i = 0; i < count; i++)
{
var decoded = DecodeAt(type, i * stride, null);
arr[i] = decoded is null ? default! : (T)decoded;
}
return arr;
}
}
@@ -0,0 +1,107 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Native <see cref="IAbCipTemplateReader"/>: Get Attribute List then chunked Read Template on the
/// device's shared scheduler, returning the 14-byte-header buffer <see cref="CipTemplateObjectDecoder"/>
/// parses. The lease is held for one read and released when it completes.
/// </summary>
internal sealed class NativeAbCipTemplateReader : IAbCipTemplateReader
{
/// <summary>Request bytes of a Read Template that are not data room (service, path, offset, count).</summary>
private const int RequestOverhead = 16;
/// <summary>Bytes of the template definition header not counted in the Read Template length.</summary>
private const int DefinitionHeaderBytes = 23;
/// <summary>Upper bound on a template's Read Template length; Logix templates are far smaller.</summary>
internal const long MaxTemplateBytes = 64 * 1024;
private readonly Func<AbCipTagCreateParams, (RequestScheduler Scheduler, ICipChannel Channel, Func<ValueTask> Release)> _acquire;
private readonly ILogger _logger;
/// <summary>Production constructor: leases from <paramref name="pool"/>.</summary>
/// <param name="pool">The connection pool (the factory passes <see cref="DeviceSessionPool.Shared"/>).</param>
public NativeAbCipTemplateReader(DeviceSessionPool pool)
: this(p =>
{
var lease = pool.Acquire(NativeAbCipDevice.Key(p), NativeAbCipDevice.Options(p));
return (lease.Scheduler, lease.Channel, lease.DisposeAsync);
})
{
}
/// <summary>Test seam: supplies the scheduler, channel and release directly.</summary>
/// <param name="acquire">Acquires the connection parts for a device.</param>
/// <param name="logger">Receives release failures; null discards them.</param>
internal NativeAbCipTemplateReader(
Func<AbCipTagCreateParams, (RequestScheduler Scheduler, ICipChannel Channel, Func<ValueTask> Release)> acquire,
ILogger? logger = null)
{
_acquire = acquire;
_logger = logger ?? NullLogger.Instance;
}
/// <inheritdoc />
public async Task<byte[]> ReadAsync(
AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken cancellationToken)
{
if (templateInstanceId > ushort.MaxValue)
throw new ArgumentOutOfRangeException(nameof(templateInstanceId), "Template ids are 16-bit.");
var id = (ushort)templateInstanceId;
var (scheduler, channel, release) = _acquire(deviceParams);
try
{
var attrReply = await scheduler.SendAsync(TemplateObjectServices.GetAttributes(id), cancellationToken)
.ConfigureAwait(false);
var attrs = TemplateObjectServices.ParseAttributes(attrReply);
// The CIP-documented Read Template length: definition words * 4 - 23.
var total = checked((long)attrs.DefinitionWords * 4 - DefinitionHeaderBytes);
if (total < 0 || total > MaxTemplateBytes)
throw new AbTransportException(AbStatus.BadReply,
$"Template {id} reports an impossible definition size of {attrs.DefinitionWords} words.");
using var raw = new MemoryStream();
long offset = 0;
while (offset < total)
{
var chunk = (ushort)Math.Min(total - offset, channel.MaxRequestBytes - RequestOverhead);
var reply = await scheduler
.SendAsync(TemplateObjectServices.ReadTemplate(id, (uint)offset, chunk), cancellationToken, exclusive: true)
.ConfigureAwait(false);
if (reply.GeneralStatus is not (0x00 or 0x06))
throw new AbTransportException(reply.Status,
$"Read Template {id} failed: general 0x{reply.GeneralStatus:X2}.");
var more = TemplateObjectServices.ReadHasMore(reply);
if (more && reply.Data.IsEmpty)
throw new AbTransportException(AbStatus.BadReply, $"Read Template {id} reported more data but returned none.");
raw.Write(reply.Data.Span);
offset += reply.Data.Length;
if (!more) break;
}
return TemplateObjectServices.BuildDecoderBuffer(id, attrs, raw.ToArray());
}
finally
{
NativeLease.ReleaseBounded(release, _logger);
}
}
/// <summary>Nothing to release — each read owns and releases its own lease.</summary>
public void Dispose() { }
}
/// <summary>Factory for <see cref="NativeAbCipTemplateReader"/>.</summary>
internal sealed class NativeAbCipTemplateReaderFactory : IAbCipTemplateReaderFactory
{
/// <inheritdoc />
public IAbCipTemplateReader Create() => new NativeAbCipTemplateReader(DeviceSessionPool.Shared);
}
@@ -41,7 +41,12 @@ public sealed record AbCipPlcFamilyProfile(
public static readonly AbCipPlcFamilyProfile CompactLogix = new(
LibplctagPlcAttribute: "compactlogix",
DefaultConnectionSize: 504, // 5069-L3x narrower buffer; safe baseline that never over-shoots
// Large Forward Open, matching libplctag: `plc=compactlogix` opens Large (0x5B, 4002) and the
// native transport must not pack into a third of the window (T21 latency harness: 504 cost ~3x
// the round trips per poll). A pre-v20 CompactLogix (1769-L3x) or a bridge that refuses the Large open
// is covered by CipConnection's fallback — a regular open at min(size, 504) inside the same
// throttled attempt. An explicit ConnectionSize (driver options or per device) still wins.
DefaultConnectionSize: 4002,
DefaultCipPath: "1,0",
SupportsRequestPacking: true,
SupportsConnectedMessaging: true,
@@ -0,0 +1,74 @@
using System.Runtime.CompilerServices;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
/// Transport-neutral <see cref="IAbCipTagEnumerator"/> that walks a Logix controller's symbol table
/// by reading the <c>@tags</c> listing through an injected reader + decoding the CIP Symbol Object
/// bytes with <see cref="CipSymbolObjectDecoder"/>.
/// </summary>
/// <remarks>
/// <para>The reader returns the raw Symbol Object bytes for a listing name (libplctag's
/// <c>Tag.GetBuffer()</c> for <c>@tags</c>, or the native paged Instance Attribute List). The decoder walks the concatenated entries + emits
/// <see cref="AbCipDiscoveredTag"/> records matching our driver surface.</para>
///
/// <para><see cref="EmptyAbCipTagEnumerator"/> remains available for tests that don't want
/// to touch the native library; the production factory default
/// (<see cref="LibplctagTagEnumeratorFactory"/>) wires this implementation in.</para>
/// </remarks>
internal sealed class SymbolListingEnumerator : IAbCipTagEnumerator
{
private readonly Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> _readListing;
/// <summary>Enumerator over a transport-specific listing reader.</summary>
/// <param name="readListing">Reads one symbol listing (<c>@tags</c> or <c>Program:X.@tags</c>) and
/// returns its raw Symbol Object bytes.</param>
public SymbolListingEnumerator(Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> readListing)
=> _readListing = readListing;
/// <inheritdoc />
/// <remarks>
/// A controller's <c>@tags</c> listing holds controller-scope tags plus one system entry per
/// program (<c>Program:UN01_Press</c>); the program's own tags are listed only by
/// <c>Program:&lt;name&gt;.@tags</c>. So the controller listing is read first, then each program's
/// listing, decoded with that program as the scope. A program whose listing cannot be read is
/// skipped — browse is best-effort and one unreadable program must not cost the others.
/// </remarks>
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
AbCipTagCreateParams deviceParams,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var buffer = await _readListing(deviceParams, "@tags", cancellationToken).ConfigureAwait(false);
// The decoder already surfaces each struct tag's CIP template instance id on
// AbCipDiscoveredTag.TemplateInstanceId (from the lower 12 bits of the symbol type), so
// re-yielding the decoded records as-is carries it through to DiscoverAsync's UDT fan-out.
var programs = new List<string>();
foreach (var tag in CipSymbolObjectDecoder.Decode(buffer))
{
if (CipSymbolObjectDecoder.ProgramNameOf(tag) is { } program) programs.Add(program);
yield return tag;
}
foreach (var program in programs)
{
byte[] programBuffer;
try
{
programBuffer = await _readListing(deviceParams, $"Program:{program}.@tags", cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) { throw; }
catch (Exception)
{
continue;
}
foreach (var tag in CipSymbolObjectDecoder.Decode(programBuffer, programScope: program))
yield return tag;
}
}
/// <summary>Nothing to release — the reader owns any per-listing resources.</summary>
public void Dispose() { }
}
@@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts\ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
</ItemGroup>
@@ -27,6 +28,7 @@
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests"/>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests"/>
</ItemGroup>
</Project>
@@ -25,6 +25,23 @@ public sealed class AbLegacyDriverOptions
/// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
/// <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.Libplctag"/> for
/// every family. <see cref="AbLegacyTransport.Native"/> is opt-in (final review I3: on a wire it is proven
/// only for N-file reads/writes and one F8 read). An explicit value always wins. The
/// effective transport is resolved in one place, <c>AbLegacyTransportResolver</c>.
/// <para>Changing this takes effect when the driver instance is RECREATED, not on
/// <c>ReinitializeAsync</c>: the tag factory is chosen once in the constructor and held across
/// Shutdown+Initialize. A deploy that changes the driver config stops and respawns the instance
/// (<c>DriverSpawnPlanner</c>, #516), so an operator edit does recreate it.</para>
/// </summary>
/// <remarks>Null is never written: an absent key IS the "use the default" encoding, so a round-trip
/// through any serializer (the AdminUI form included) keeps a defaulted driver defaulted rather than
/// pinning it to whatever the default happened to be on the day it was saved.</remarks>
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public AbLegacyTransport? Transport { get; init; }
/// <summary>Gets or sets the probe (connectivity check) options.</summary>
public AbLegacyProbeOptions Probe { get; init; } = new();
@@ -0,0 +1,17 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// Which PCCC transport stack an AbLegacy driver instance (and its Test Connect probe / CLI run)
/// talks to the controller through. Serialises as a STRING in driver config JSON.
/// </summary>
public enum AbLegacyTransport
{
/// <summary>The libplctag-backed transport. The default.</summary>
Libplctag,
/// <summary>
/// The native managed EtherNet/IP + PCCC transport. Known gaps: PLC-5 supports N-file reads only,
/// writes to status-bit sub-elements are unsupported, and LogixPccc framing is unverified.
/// </summary>
Native,
}
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
@@ -68,7 +69,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
_tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
// 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);
_tagFactory = tagFactory ?? (_transport == AbLegacyTransport.Native
? new NativeAbLegacyTagFactory()
: new LibplctagLegacyTagFactory());
_logger = logger ?? NullLogger<AbLegacyDriver>.Instance;
_resolver = new EquipmentTagRefResolver<AbLegacyTagDefinition>(
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
@@ -85,6 +92,23 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
new KeyValuePair<string, object?>(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, driverInstanceId)));
}
/// <summary>The tag factory chosen at construction; exposed so tests can pin transport selection.</summary>
internal IAbLegacyTagFactory TagFactory => _tagFactory;
/// <summary>The effective transport resolved at construction (see <see cref="AbLegacyTransportResolver"/>).
/// Describes the OPTION only: an injected <c>tagFactory</c> (tests) is used regardless.</summary>
internal AbLegacyTransport EffectiveTransport => _transport;
// The transport chosen at construction (see the ctor); logged at every init so an operator can tell
// from the node log which stack serves this instance.
private readonly AbLegacyTransport _transport;
// Configured-vs-effective rendering of _transport for the init log, e.g. "Libplctag (default)".
private readonly string _transportDescription;
// Set once the explicit-Native-on-a-limited-family Warning has been logged for this instance.
private bool _limitedFamilyWarned;
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
@@ -122,6 +146,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
_logger.LogInformation(
"AbLegacy driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}",
_driverInstanceId, _transportDescription, _tagFactory.GetType().Name);
try
{
// #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver
@@ -130,6 +157,21 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
if (HasConfigBody(driverConfigJson))
_options = AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
// Native is opt-in for every family (the default is libplctag); an operator may pick it explicitly
// even for PLC-5 / LogixPccc. Allowed, but the limitation is client-visible, so name it ONCE per instance —
// not on every init, which includes every comms-loss reconnect retry (Task 22 review r1 M4).
if (!_limitedFamilyWarned
&& _transport == AbLegacyTransport.Native
&& AbLegacyTransportResolver.NativeLimitedFamilies(_options.Devices) is { Count: > 0 } limited)
{
_limitedFamilyWarned = true;
_logger.LogWarning(
"AbLegacy driver {DriverInstanceId} runs the Native transport against {Families} device(s); native " +
"support is limited there (PLC-5: N-file reads only; LogixPccc: framing unverified). Set " +
"\"transport\": \"Libplctag\" to use the fallback stack.",
_driverInstanceId, string.Join(", ", limited));
}
foreach (var device in _options.Devices)
{
var addr = AbLegacyHostAddress.TryParse(device.HostAddress)
@@ -315,14 +357,14 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
// Evict the stale handle so the next call re-creates it (mirrors AbCip).
EvictRuntime(device, def.Name);
results[i] = new DataValueSnapshot(null,
AbLegacyStatusMapper.MapLibplctagStatus(status), null, now);
AbLegacyStatusMapper.MapStatus(status), null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
$"libplctag status {status} reading {reference}");
$"transport status {status} reading {reference}");
if (!device.FirstNonZeroStatusLogged)
{
device.FirstNonZeroStatusLogged = true;
_logger.LogWarning(
"AbLegacy non-zero libplctag status. Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Status={Status}",
"AbLegacy non-zero transport status. Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Status={Status}",
_driverInstanceId, def.DeviceHostAddress, reference, status);
}
continue;
@@ -335,6 +377,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (OperationCanceledException) { throw; }
catch (AbTransportException ex) when (IsLocalRefusal(ex))
{
// The runtime refused the operation before the wire (e.g. a native PLC-5 non-N read, #571).
// Not a comms fault: BadNotSupported, keep the handle, leave health alone.
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
}
catch (Exception ex)
{
// Transport exception — evict so the next read creates a fresh handle (mirrors AbCip).
@@ -416,7 +464,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
{
// Evict the stale handle so the next call re-creates it (mirrors AbCip).
EvictRuntime(device, def.Name);
results[i] = new WriteResult(AbLegacyStatusMapper.MapLibplctagStatus(status));
results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(status));
}
else
{
@@ -437,6 +485,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
{
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
}
catch (AbTransportException ex) when (IsLocalRefusal(ex))
{
// The runtime refused the write before the wire — a native status-bit sub-element write or a
// PLC-5 write (#571). Not a comms fault: BadNotSupported, keep the handle, leave health alone.
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
}
catch (Exception ex)
{
// Transport exception — evict so the next write/read creates a fresh handle (mirrors AbCip).
@@ -654,7 +708,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
var readStatus = parentRuntime.GetStatus();
if (readStatus != 0) return AbLegacyStatusMapper.MapLibplctagStatus(readStatus);
if (readStatus != 0) return AbLegacyStatusMapper.MapStatus(readStatus);
// Mask the decoded word to its native width: DecodeValue for a 16-bit Int returns a
// sign-extended int, so a word with bit 15 set decodes negative. Masking pins the
@@ -673,7 +727,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
var writeStatus = parentRuntime.GetStatus();
return writeStatus == 0
? AbLegacyStatusMapper.Good
: AbLegacyStatusMapper.MapLibplctagStatus(writeStatus);
: AbLegacyStatusMapper.MapStatus(writeStatus);
}
finally
{
@@ -703,16 +757,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parentName,
Timeout: _options.Timeout));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch
{
runtime.Dispose();
throw;
}
device.ParentRuntimes[parentName] = runtime;
await InitializeOrKeepRefusedAsync(runtime, () => device.ParentRuntimes[parentName] = runtime, ct)
.ConfigureAwait(false);
return runtime;
}
finally
@@ -739,6 +785,39 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
private static int EffectiveArrayLength(AbLegacyTagDefinition def) =>
def.ArrayLength is int len && len >= 1 ? Math.Min(len, AbLegacyArray.MaxElements) : 1;
/// <summary>
/// True for a deterministic refusal of the operation itself (<see cref="AbStatus.Unsupported"/>) — the
/// native runtime raises it BEFORE anything reaches the wire (status-bit writes, PLC-5 non-N reads and
/// all PLC-5 writes), and libplctag's ErrorUnsupported maps to it too. It says nothing about the
/// device's reachability, so it must not evict the handle or degrade driver health.
/// </summary>
internal static bool IsLocalRefusal(Exception ex) =>
ex is AbTransportException { Status: AbStatus.Unsupported };
/// <summary>
/// Initializes a freshly created runtime. A NATIVE runtime whose init (one read) is refused locally is
/// still KEPT: the refusal is deterministic and per-operation, so caching it avoids re-creating — and
/// re-leasing the pooled device connection — on every poll. Any other failure disposes it, as before.
/// </summary>
private static async Task InitializeOrKeepRefusedAsync(IAbLegacyTagRuntime runtime, Action keep, CancellationToken ct)
{
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch (Exception ex) when (IsLocalRefusal(ex) && runtime is NativeAbLegacyTagRuntime)
{
keep();
throw;
}
catch
{
runtime.Dispose();
throw;
}
keep();
}
private async Task<IAbLegacyTagRuntime> EnsureTagRuntimeAsync(
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
{
@@ -765,16 +844,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
TagName: parsed.ToLibplctagName(),
Timeout: _options.Timeout,
ElementCount: EffectiveArrayLength(def)));
try
{
await runtime.InitializeAsync(ct).ConfigureAwait(false);
}
catch
{
runtime.Dispose();
throw;
}
device.Runtimes[def.Name] = runtime;
await InitializeOrKeepRefusedAsync(runtime, () => device.Runtimes[def.Name] = runtime, ct)
.ConfigureAwait(false);
return runtime;
}
finally
@@ -97,6 +97,11 @@ public static class AbLegacyDriverFactoryExtensions
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
},
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
// Absent ⇒ null ("use the default" = Libplctag, resolved by AbLegacyTransportResolver);
// a bad value still fails exactly as any other bad enum field does.
Transport = string.IsNullOrWhiteSpace(dto.Transport)
? null
: ParseEnum<AbLegacyTransport>(dto.Transport, driverInstanceId, "Transport"),
};
return options;
@@ -125,7 +130,8 @@ public static class AbLegacyDriverFactoryExtensions
throw new InvalidOperationException(
$"AB Legacy {(tagName is null ? "config" : $"tag '{tagName}'")} in '{driverInstanceId}' missing {field}");
}
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
// Enum.TryParse also accepts any integer string ("7"), defined or not (Task 22 review r1 M2).
return Enum.TryParse<T>(raw, ignoreCase: true, out var v) && Enum.IsDefined(v)
? v
: throw new InvalidOperationException(
$"AB Legacy {(tagName is null ? "config" : $"tag '{tagName}'")} has unknown {field} '{raw}'. " +
@@ -146,6 +152,11 @@ public static class AbLegacyDriverFactoryExtensions
/// </summary>
public int? TimeoutMs { get; init; }
/// <summary>
/// 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; }
/// <summary>
/// Gets or sets the list of devices to connect to.
/// </summary>
@@ -2,7 +2,7 @@ using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
@@ -18,23 +18,25 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// non-PCCC server) returns <c>Ok = false</c> with a "handshake failed" message instead of
/// a false-positive green tick.
/// <para>
/// Status-code discrimination: libplctag statuses that indicate the controller answered
/// Status-code discrimination: transport statuses that indicate the controller answered
/// the PCCC session but could not resolve the probe tag
/// (<see cref="Status.ErrorNotFound"/>, <see cref="Status.ErrorNoMatch"/>,
/// <see cref="Status.ErrorBadDevice"/>) are treated as "reachable — Ok = true" because
/// (<see cref="AbStatus.NotFound"/>) are treated as "reachable — Ok = true" because
/// a non-existent tag name proves PCCC connectivity. Statuses that indicate a transport or
/// session failure (<see cref="Status.ErrorTimeout"/>, <see cref="Status.ErrorBadConnection"/>,
/// <see cref="Status.ErrorBadGateway"/>, <see cref="Status.ErrorWinsock"/>,
/// <see cref="Status.ErrorOpen"/>, <see cref="Status.ErrorClose"/>,
/// <see cref="Status.ErrorRead"/>, <see cref="Status.ErrorWrite"/>,
/// <see cref="Status.ErrorBadReply"/>, <see cref="Status.ErrorRemoteErr"/>,
/// <see cref="Status.ErrorPartial"/>, <see cref="Status.ErrorAbort"/>) are treated as
/// session failure (<see cref="AbStatus.Timeout"/>, <see cref="AbStatus.BadConnection"/>,
/// <see cref="AbStatus.BadReply"/>, <see cref="AbStatus.Partial"/>, <see cref="AbStatus.Abort"/>) are treated as
/// "handshake failed — Ok = false". All other error statuses default to
/// "handshake failed" (conservative).
/// </para>
/// </summary>
public sealed class AbLegacyDriverProbe : IDriverProbe
{
private readonly IAbLegacyTagFactory? _tagFactory;
/// <summary>Initializes a new instance of the <see cref="AbLegacyDriverProbe"/> class.</summary>
/// <param name="tagFactory">The tag factory used for the PCCC handshake; when null the config's
/// <c>Transport</c> selects the native or libplctag-backed one.</param>
public AbLegacyDriverProbe(IAbLegacyTagFactory? tagFactory = null) => _tagFactory = tagFactory;
private static readonly JsonSerializerOptions _opts = new()
{
PropertyNameCaseInsensitive = true,
@@ -104,7 +106,10 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
TagName: tagName,
Timeout: timeout);
var rt = new LibplctagLegacyTagRuntime(p);
var factory = _tagFactory ?? (AbLegacyTransportResolver.Resolve(opts) == AbLegacyTransport.Native
? new NativeAbLegacyTagFactory()
: new LibplctagLegacyTagFactory());
var rt = factory.Create(p);
try
{
await rt.InitializeAsync(cts.Token);
@@ -112,10 +117,10 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
// InitializeAsync completed without throwing — either the tag was found (Ok) or
// libplctag returned a non-exception status. Check via GetStatus().
var statusCode = (Status)rt.GetStatus();
var statusCode = (AbStatus)rt.GetStatus();
if (IsReachableStatus(statusCode))
{
var msg = statusCode == Status.Ok
var msg = statusCode == AbStatus.Ok
? "PCCC session OK"
: "PCCC session OK (controller reachable; probe tag not found)";
return new(true, msg, sw.Elapsed);
@@ -128,10 +133,10 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
{
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
}
catch (LibPlcTagException ex)
catch (AbTransportException ex)
{
// LibPlcTagException carries the Status; classify as reachable vs transport failure.
if (IsReachableException(ex))
// AbTransportException carries the Status; classify as reachable vs transport failure.
if (IsReachableStatus(ex.Status))
{
sw.Stop();
return new(true, "PCCC session OK (controller reachable; probe tag not found)", sw.Elapsed);
@@ -150,30 +155,13 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
}
/// <summary>
/// Returns <c>true</c> for libplctag statuses that indicate the PCCC controller answered
/// but the probe tag name was not found or did not match — sufficient to confirm PCCC
/// reachability. Returns <c>true</c> for <see cref="Status.Ok"/> (tag was found).
/// Returns <c>true</c> for statuses that indicate the PCCC controller answered but the
/// probe tag was not found — sufficient to confirm PCCC reachability. Also <c>true</c> for
/// <see cref="AbStatus.Ok"/> (tag was found).
/// </summary>
private static bool IsReachableStatus(Status s) => s is
Status.Ok or
Status.ErrorNotFound or
Status.ErrorNoMatch or
Status.ErrorBadDevice;
/// <summary>
/// Inspects a <see cref="LibPlcTagException"/> message to decide whether the error is a
/// "controller answered PCCC but tag not found" condition (reachable) versus a session /
/// transport failure (unreachable). Falls back to checking the exception message for
/// known tag-not-found phrases used by the libplctag native library.
/// </summary>
private static bool IsReachableException(LibPlcTagException ex)
{
// libplctag wraps the Status in the message as "STATUS_NOT_FOUND", "STATUS_NO_MATCH", etc.
var msg = ex.Message ?? string.Empty;
return msg.Contains("NOT_FOUND", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("NO_MATCH", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("BAD_DEVICE", StringComparison.OrdinalIgnoreCase);
}
/// <param name="s">The transport status.</param>
/// <returns>Whether the status proves the controller answered.</returns>
internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
private static (AbLegacyHostAddress? parsed, AbLegacyDeviceOptions? device) ExtractTarget(AbLegacyDriverOptions opts)
{
@@ -1,9 +1,9 @@
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// Maps libplctag status codes + PCCC STS/EXT_STS bytes to OPC UA StatusCodes. Mirrors the
/// Maps transport status codes + PCCC STS/EXT_STS bytes to OPC UA StatusCodes. Mirrors the
/// AbCip mapper — PCCC errors roughly align with CIP general-status in shape but with a
/// different byte vocabulary (PCCC STS nibble-low + EXT_STS on code 0x0F).
/// </summary>
@@ -22,44 +22,41 @@ public static class AbLegacyStatusMapper
public const uint BadTypeMismatch = 0x80740000u;
/// <summary>
/// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here
/// is <c>(int)Tag.GetStatus()</c> — the underlying value of the libplctag.NET
/// <see cref="Status"/> enum. Delegates to the strongly-typed overload so the mapping
/// stays correct regardless of how the wrapper renumbers native PLCTAG_ERR_* constants
/// in future releases.
/// Map the integer form of an <see cref="AbStatus"/> (what <see cref="IAbLegacyTagRuntime.GetStatus"/>
/// returns) to an OPC UA StatusCode.
/// </summary>
/// <param name="status">The integer status code from libplctag.</param>
/// <param name="status">The integer transport status.</param>
/// <returns>The corresponding OPC UA status code.</returns>
public static uint MapLibplctagStatus(int status) => MapLibplctagStatus((Status)status);
public static uint MapStatus(int status) => MapStatus((AbStatus)status);
/// <summary>
/// Map a libplctag.NET <see cref="Status"/> enum value to an OPC UA StatusCode. This is
/// the canonical core; the <c>int</c> overload exists only for the
/// <see cref="IAbLegacyTagRuntime.GetStatus"/> seam which boxes the enum as an int.
/// </summary>
/// <param name="status">The libplctag Status enum value.</param>
/// <summary>Map a transport-neutral <see cref="AbStatus"/> to an OPC UA StatusCode.</summary>
/// <param name="status">The transport status.</param>
/// <returns>The corresponding OPC UA status code.</returns>
public static uint MapLibplctagStatus(Status status) => status switch
public static uint MapStatus(AbStatus status) => status switch
{
Status.Ok => Good,
Status.Pending => GoodMoreData,
Status.ErrorTimeout => BadTimeout,
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => BadNodeIdUnknown,
Status.ErrorNotAllowed => BadNotWritable,
Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => BadOutOfRange,
Status.ErrorUnsupported or Status.ErrorNotImplemented => BadNotSupported,
Status.ErrorBadConnection or Status.ErrorBadGateway or Status.ErrorBadReply
or Status.ErrorWinsock or Status.ErrorOpen or Status.ErrorClose
or Status.ErrorRead or Status.ErrorWrite or Status.ErrorRemoteErr
or Status.ErrorPartial or Status.ErrorAbort => BadCommunicationError,
_ => BadCommunicationError,
AbStatus.Ok => Good,
AbStatus.Pending => GoodMoreData,
AbStatus.Timeout => BadTimeout,
AbStatus.NotFound => BadNodeIdUnknown,
AbStatus.NotAllowed => BadNotWritable,
AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall => BadOutOfRange,
AbStatus.Unsupported => BadNotSupported,
AbStatus.TypeMismatch => BadTypeMismatch,
AbStatus.DeviceStateConflict => BadDeviceFailure,
_ => BadCommunicationError, // BadConnection, BadReply, Partial, Abort
};
/// <summary>Map an exception thrown by a transport to an OPC UA StatusCode.</summary>
/// <param name="ex">The exception.</param>
/// <returns>The mapped status for an <see cref="AbTransportException"/>, else BadCommunicationError.</returns>
public static uint MapException(Exception ex) =>
ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError;
/// <summary>
/// Map a PCCC STS (status) byte. Common codes per AB PCCC reference:
/// 0x00 = success, 0x10 = illegal command, 0x20 = bad address, 0x30 = protected,
/// 0x40 = programmer busy, 0x50 = file locked, 0xF0 = extended status follows.
/// libplctag surfaces only its own <see cref="Status"/> enum rather than exposing
/// the transports surface only an <see cref="AbStatus"/> rather than exposing
/// the raw STS byte, so this method is not wired into the current read/write path.
/// It is retained as the reference mapping for future PCCC-STS inspection.
/// </summary>
@@ -0,0 +1,57 @@
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// The ONE place an AbLegacy driver's effective transport is decided from its configured
/// <see cref="AbLegacyDriverOptions.Transport"/>. The driver constructor, the Test Connect probe and the
/// CLI (through the driver) all resolve through here.
/// <para>An absent key means <see cref="Default"/> = <see cref="AbLegacyTransport.Libplctag"/> for EVERY
/// family (final review I3): on a wire the native PCCC path is proven only for N-file reads and writes
/// and one F8 read, so Native stays opt-in. An explicit value always wins (an operator choice). An
/// explicit Native on a PLC-5 / LogixPccc device is allowed but logs a once-per-instance Warning
/// (<see cref="NativeLimitedFamilies"/>).</para>
/// </summary>
public static class AbLegacyTransportResolver
{
/// <summary>Returns true for a family whose native transport support is limited.</summary>
/// <param name="family">The PLC family.</param>
/// <returns>True for PLC-5 and LogixPccc.</returns>
public static bool IsNativeLimited(AbLegacyPlcFamily family) =>
family is AbLegacyPlcFamily.Plc5 or AbLegacyPlcFamily.LogixPccc;
/// <summary>Returns the distinct native-limited families among <paramref name="devices"/>.</summary>
/// <param name="devices">The configured devices.</param>
/// <returns>The limited families present, in declaration order.</returns>
public static IReadOnlyList<AbLegacyPlcFamily> NativeLimitedFamilies(IEnumerable<AbLegacyDeviceOptions> devices)
{
ArgumentNullException.ThrowIfNull(devices);
return [.. devices.Select(d => d.PlcFamily).Where(IsNativeLimited).Distinct()];
}
/// <summary>The transport an absent / null <c>transport</c> key selects (all families).</summary>
public const AbLegacyTransport Default = AbLegacyTransport.Libplctag;
/// <summary>Returns the effective transport: an explicit value always wins; null means <see cref="Default"/>.</summary>
/// <param name="configured">The configured transport, or null for "use the default".</param>
/// <returns>The transport the driver runs on.</returns>
public static AbLegacyTransport Resolve(AbLegacyTransport? configured) => configured ?? Default;
/// <summary>Returns the effective transport for <paramref name="options"/>.</summary>
/// <param name="options">The driver options.</param>
/// <returns>The transport the driver runs on.</returns>
public static AbLegacyTransport Resolve(AbLegacyDriverOptions options)
{
ArgumentNullException.ThrowIfNull(options);
return Resolve(options.Transport);
}
/// <summary>
/// Operator-facing description of the configured-vs-effective transport for the init log line:
/// <c>Libplctag (default)</c> when the key was absent, <c>Native (configured)</c> when explicit.
/// </summary>
/// <param name="configured">The configured transport, or null.</param>
/// <returns>A short description.</returns>
public static string Describe(AbLegacyTransport? configured) =>
configured is null ? $"{Resolve(configured)} (default)" : $"{configured} (configured)";
}
@@ -0,0 +1,45 @@
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>The ONLY place libplctag's <see cref="Status"/> meets <see cref="AbStatus"/> in AbLegacy.</summary>
internal static class LibplctagLegacyStatusTranslation
{
/// <summary>Translates a libplctag status into the transport-neutral <see cref="AbStatus"/>.</summary>
/// <param name="s">The libplctag status.</param>
/// <returns>The corresponding <see cref="AbStatus"/>.</returns>
public static AbStatus ToAbStatus(Status s) => s switch
{
Status.Ok => AbStatus.Ok,
Status.Pending => AbStatus.Pending,
Status.ErrorTimeout => AbStatus.Timeout,
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.NotFound,
Status.ErrorNotAllowed => AbStatus.NotAllowed,
Status.ErrorOutOfBounds => AbStatus.OutOfBounds,
Status.ErrorTooLarge => AbStatus.TooLarge,
Status.ErrorTooSmall => AbStatus.TooSmall,
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbStatus.Unsupported,
Status.ErrorPartial => AbStatus.Partial,
Status.ErrorAbort => AbStatus.Abort,
Status.ErrorBadReply => AbStatus.BadReply,
_ => AbStatus.BadConnection,
};
/// <summary>libplctag builds the exception from <c>Status.ToString()</c>, so the message is the enum name.</summary>
/// <param name="ex">The libplctag exception.</param>
/// <returns>The corresponding <see cref="AbStatus"/>.</returns>
public static AbStatus ToAbStatus(LibPlcTagException ex) =>
Enum.TryParse<Status>((ex.Message ?? "").Trim(), ignoreCase: true, out var s) && Enum.IsDefined(s)
? ToAbStatus(s)
: AbStatus.BadConnection;
/// <summary>Runs <paramref name="op"/>, rethrowing libplctag failures as <see cref="AbTransportException"/>.</summary>
/// <param name="op">The libplctag operation.</param>
/// <returns>A task completing with the operation.</returns>
public static async Task Wrap(Func<Task> op)
{
try { await op().ConfigureAwait(false); }
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
}
}
@@ -1,4 +1,5 @@
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
@@ -12,35 +13,86 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
{
private readonly Tag _tag;
/// <summary>
/// The status the native tag held when a failed initialisation destroyed it (see
/// <see cref="LibplctagOrphanRelease"/>); null while the wrapper's own handle is authoritative.
/// </summary>
private Status? _releasedStatus;
/// <summary>The native handle a failed initialisation destroyed (0 if none); the wrapper's own field is cleared.</summary>
private int _releasedHandle;
/// <summary>Initializes a new instance of the <see cref="LibplctagLegacyTagRuntime"/> class.</summary>
/// <param name="p">The parameters for tag creation.</param>
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
{
_tag = new Tag
_tag = BuildTag(p);
}
/// <summary>Builds the (uninitialized) libplctag tag for <paramref name="p"/>.</summary>
/// <param name="p">The tag creation parameters.</param>
internal static Tag BuildTag(AbLegacyTagCreateParams p) =>
new Tag
{
// host:port — see AbLegacyTagCreateParams/AbCipTagCreateParams.LibplctagGateway.
// Passing p.Gateway alone drops the authored port onto libplctag's 44818 default (#534).
Gateway = p.LibplctagGateway,
Path = p.CipPath,
Path = LibplctagPath.Normalize(p.CipPath),
PlcType = MapPlcType(p.LibplctagPlcAttribute),
Protocol = Protocol.ab_eip, // PCCC-over-EIP; libplctag routes via the PlcType-specific PCCC layer
Name = p.TagName,
Timeout = p.Timeout,
ElementCount = p.ElementCount > 1 ? p.ElementCount : null,
};
/// <inheritdoc />
public Task InitializeAsync(CancellationToken cancellationToken) =>
RunReleasingOrphanAsync(() => _tag.InitializeAsync(cancellationToken));
/// <inheritdoc />
/// <remarks>libplctag initialises an uninitialised tag on its first read, so this path can orphan one too.</remarks>
public Task ReadAsync(CancellationToken cancellationToken) =>
RunReleasingOrphanAsync(() => _tag.ReadAsync(cancellationToken));
/// <inheritdoc />
public Task WriteAsync(CancellationToken cancellationToken) =>
RunReleasingOrphanAsync(() => _tag.WriteAsync(cancellationToken));
/// <summary>
/// Runs a libplctag operation; when it fails and leaves the tag uninitialised, destroys the native tag it
/// created before the failure propagates, so its callback can never fire into a collected delegate (T21 press
/// pass 1 crash). The status that tag held is kept for <see cref="GetStatus"/>.
/// </summary>
private async Task RunReleasingOrphanAsync(Func<Task> op)
{
try
{
await LibplctagLegacyStatusTranslation.Wrap(op).ConfigureAwait(false);
_releasedStatus = null;
_releasedHandle = 0;
}
catch
{
var handle = LibplctagOrphanRelease.HandleOf(_tag);
if (LibplctagOrphanRelease.ReleaseIfOrphaned(_tag) is { } status)
{
_releasedStatus = status;
_releasedHandle = handle;
}
throw;
}
}
/// <inheritdoc />
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
/// <summary>The native libplctag handle this runtime's tag holds, or the one a failed initialisation destroyed
/// (0 if none was created). Tests only.</summary>
internal int NativeHandle => _releasedHandle != 0 ? _releasedHandle : LibplctagOrphanRelease.HandleOf(_tag);
/// <summary>The handle the libplctag wrapper itself still holds (0 once a release cleared it). Tests only.</summary>
internal int WrapperHandle => LibplctagOrphanRelease.HandleOf(_tag);
/// <inheritdoc />
public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken);
/// <inheritdoc />
public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken);
/// <inheritdoc />
public int GetStatus() => (int)_tag.GetStatus();
public int GetStatus() =>
(int)LibplctagLegacyStatusTranslation.ToAbStatus(_releasedStatus ?? _tag.GetStatus());
/// <inheritdoc />
public object? DecodeValue(AbLegacyDataType type, int? bitIndex) => type switch
@@ -0,0 +1,143 @@
using System.Collections.Concurrent;
using System.Reflection;
using libplctag;
using libplctag.NativeImport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// Releases the native tag a FAILED libplctag initialisation leaves behind (T21 press pass 1 crash).
/// </summary>
/// <remarks>
/// <para>libplctag.NET 1.5.2's <c>NativeTagWrapper.InitializeAsync</c> calls <c>plc_tag_create_ex</c>, which returns a
/// native handle with the wrapper's <c>callback_func_ex</c> delegate registered, and only then waits for the
/// <c>Created</c> event. When that event carries an error (a tag the controller does not have, a refused path, ...)
/// it throws WITHOUT setting <c>_isInitialized</c>, and <c>Dispose()</c> unregisters and destroys only
/// <c>if (_isInitialized)</c>. So the native tag outlives its wrapper with the callback still registered; once the
/// wrapper is collected the delegate's thunk is freed, and the next event libplctag raises on that tag — at the
/// latest the <c>DESTROYED</c> event from <c>plc_tag_shutdown</c>, which the native library runs from
/// <c>atexit</c> — kills the process with "A callback was made on a garbage collected delegate of type
/// 'libplctag.NativeImport!libplctag.NativeImport.plctag+callback_func_ex::Invoke'". A timed-out initialisation
/// unregisters the callback but never destroys the handle either, so that native tag keeps reconnecting in the
/// background for the life of the process.</para>
/// <para>The wrapper exposes no handle, so it is read by reflection from the two private fields of the pinned
/// 1.5.2 wrapper (<c>Tag._tag</c>, <c>NativeTagWrapper.nativeTagHandle</c>). If a future wrapper renames them the
/// release cannot run; the tag is then rooted for the life of the process instead, which keeps its delegate
/// alive. That is no crash but an UNBOUNDED leak — one native tag (holding its libplctag session open) per failed
/// create, and the data path re-creates a NotFound tag on every read — so <c>LibplctagLegacyOrphanReleaseTests</c> fails, so the bump is noticed.</para>
/// </remarks>
internal static class LibplctagOrphanRelease
{
private const BindingFlags Private = BindingFlags.NonPublic | BindingFlags.Instance;
private static readonly FieldInfo? WrapperField = typeof(Tag).GetField("_tag", Private);
// Final review M2: the field must also still be an int. A wrapper that retyped it (IntPtr, long) would make
// the cast in HandleOf throw; treating it as unreadable instead degrades to the rooting fallback ("keep
// alive"), which the pin test reports.
private static readonly FieldInfo? HandleField =
WrapperField?.FieldType.GetField("nativeTagHandle", Private) is { } f && f.FieldType == typeof(int) ? f : null;
/// <summary>Tags whose handle could not be read: kept reachable so their callback delegate is never collected.</summary>
private static readonly ConcurrentBag<Tag> Rooted = [];
private static int _released;
/// <summary>True when the pinned wrapper's private fields were found, so a failed tag can be destroyed.</summary>
internal static bool CanReadHandles => HandleField is not null && !ForceRootingForTest;
/// <summary>Test seam (review r1 M1): take the rooting fallback as if the wrapper's fields had been renamed.</summary>
internal static bool ForceRootingForTest { get; set; }
/// <summary>Orphaned native tags destroyed so far (process-wide; diagnostics and tests).</summary>
internal static int Released => Volatile.Read(ref _released);
/// <summary>Orphaned tags rooted for the process lifetime because their handle could not be read.</summary>
internal static int RootedCount => Rooted.Count;
/// <summary>The native handle the wrapper holds (0 when none was ever created, or unreadable). Never throws:
/// callers run it inside a catch block, where an exception would replace the failure being handled.</summary>
internal static int HandleOf(Tag tag)
{
try
{
return HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper
&& HandleField.GetValue(wrapper) is int handle ? handle : 0;
}
catch (Exception)
{
return 0;
}
}
/// <summary>Clears the wrapper's handle after a destroy, so a repeat release is a no-op (review r1 M2).</summary>
private static void ClearHandle(Tag tag)
{
if (HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper) HandleField.SetValue(wrapper, 0);
}
/// <summary>
/// Runs a libplctag operation that may initialise <paramref name="tag"/> (InitializeAsync, or a first
/// Read/Write, which initialise on demand). If it fails and leaves the tag uninitialised, the native tag it
/// created is unregistered and destroyed before the failure propagates.
/// </summary>
internal static async Task RunAsync(Tag tag, Func<Task> op)
{
try
{
await op().ConfigureAwait(false);
}
catch
{
ReleaseIfOrphaned(tag);
throw;
}
}
/// <inheritdoc cref="RunAsync(Tag, Func{Task})"/>
internal static async Task<T> RunAsync<T>(Tag tag, Func<Task<T>> op)
{
try
{
return await op().ConfigureAwait(false);
}
catch
{
ReleaseIfOrphaned(tag);
throw;
}
}
/// <summary>
/// Destroys the native tag behind an UNINITIALISED wrapper, unregistering its callback first. No-op for an
/// initialised tag (its own <c>Dispose</c> handles it) or one that never got a handle. Never throws.
/// </summary>
/// <returns>The status the native tag held just before it was destroyed (what <c>GetStatus()</c> would have
/// reported), or null when nothing was destroyed.</returns>
internal static Status? ReleaseIfOrphaned(Tag tag)
{
try
{
if (tag.IsInitialized) return null;
if (!CanReadHandles)
{
Rooted.Add(tag);
return null;
}
var handle = HandleOf(tag);
if (handle <= 0) return null; // plc_tag_create_ex refused synchronously: nothing native exists
var status = (Status)plctag.plc_tag_status(handle);
plctag.plc_tag_unregister_callback(handle);
plctag.plc_tag_destroy(handle);
ClearHandle(tag);
Interlocked.Increment(ref _released);
return status;
}
catch (Exception)
{
// A release must never mask the initialisation failure it follows. Root the tag so that, whatever
// state the native side is in, its callback delegate stays alive.
Rooted.Add(tag);
return null;
}
}
}
@@ -0,0 +1,13 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// Normalizes a parsed CIP route for libplctag's <c>Path</c> attribute. libplctag rejects an
/// empty-string path with <c>ErrorBadData</c>; "no route" (Micro800, unrouted MicroLogix) must be
/// expressed by omitting the attribute, i.e. <see langword="null"/>.
/// </summary>
internal static class LibplctagPath
{
/// <summary>Returns <see langword="null"/> for a null, empty or whitespace path; otherwise the path unchanged.</summary>
/// <param name="path">The parsed CIP path.</param>
public static string? Normalize(string? path) => string.IsNullOrWhiteSpace(path) ? null : path;
}
@@ -0,0 +1,14 @@
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>Creates <see cref="NativeAbLegacyTagRuntime"/> instances over a <see cref="DeviceSessionPool"/>
/// (the process-wide shared pool by default).</summary>
internal sealed class NativeAbLegacyTagFactory(DeviceSessionPool? pool = null) : IAbLegacyTagFactory
{
private readonly DeviceSessionPool _pool = pool ?? DeviceSessionPool.Shared;
/// <inheritdoc />
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams) =>
new NativeAbLegacyTagRuntime(createParams, _pool);
}
@@ -0,0 +1,392 @@
using System.Buffers.Binary;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
/// <summary>
/// Native <see cref="IAbLegacyTagRuntime"/>: PCCC data-table reads and writes carried in Execute PCCC
/// through the device's shared <see cref="RequestScheduler"/> (one per device connection, never per tag).
/// Decoded values, exceptions and statuses match <see cref="LibplctagLegacyTagRuntime"/>.
/// </summary>
internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
{
private const int DefaultPort = 44818;
private const int StringDataBytes = 82;
private const int StringElementBytes = 84;
// One process-wide TNS counter: no two in-flight requests (on any connection) share a TNS, so a stale
// reply belonging to another tag fails the TNS echo check instead of matching by accident.
private static int s_tns;
private readonly string _plc;
private readonly RequestScheduler _scheduler;
private readonly ConnectionLease? _lease;
private readonly byte[] _requestorId;
private readonly ushort _vendor;
private readonly uint _serial;
private readonly PcccAddress? _address;
private readonly Exception? _addressError;
private readonly int _elementSize;
private readonly int _count;
private readonly byte[] _buffer;
private int _status;
/// <summary>Creates a runtime that leases the shared connection for its device from <paramref name="pool"/>.</summary>
public NativeAbLegacyTagRuntime(AbLegacyTagCreateParams p, DeviceSessionPool pool)
: this(p, pool, OptionsFor(p))
{
}
private NativeAbLegacyTagRuntime(AbLegacyTagCreateParams p, DeviceSessionPool pool, ConnectionOptions options)
: this(p, pool.Acquire(KeyFor(p), options))
{
}
// The PCCC requestor id is libplctag's captured identity, not the connection's CIP originator: ab_server
// replies with that fixed id whatever was sent, and real controllers echo it (see PcccServices.RequestorVendor).
private NativeAbLegacyTagRuntime(AbLegacyTagCreateParams p, ConnectionLease lease)
: this(p, lease.Scheduler, PcccServices.RequestorVendor, PcccServices.RequestorSerial)
{
_lease = lease;
}
/// <summary>Test seam: runs over an explicit scheduler with an explicit requestor identity.</summary>
internal NativeAbLegacyTagRuntime(AbLegacyTagCreateParams p, RequestScheduler scheduler, ushort vendor, uint serial)
{
_plc = p.LibplctagPlcAttribute;
_scheduler = scheduler;
_vendor = vendor;
_serial = serial;
_requestorId = new byte[7];
_requestorId[0] = 7;
BinaryPrimitives.WriteUInt16LittleEndian(_requestorId.AsSpan(1), vendor);
BinaryPrimitives.WriteUInt32LittleEndian(_requestorId.AsSpan(3), serial);
_count = Math.Max(1, p.ElementCount);
try
{
_address = ToPcccAddress(p.TagName);
}
catch (AbTransportException ex)
{
_addressError = ex;
}
_elementSize = _address is null ? 2 : ElementSize(_address);
_buffer = new byte[_elementSize * _count];
}
/// <summary>
/// Routing per PLC family, from the golden captures (<c>Captures/README.md</c> "Routing observed"):
/// slc500 / micrologix / plc5 send Execute PCCC bare in SendRRData with no route even when a
/// <c>1,0</c> path is configured, so they are <see cref="CipRouting.Direct"/>. logixpccc sends an
/// Unconnected Send (0x52) with route <c>01 00</c> (README line ~129, scratch capture: ab_server
/// rejected it, so the shape is read from the client side), so it is <see cref="CipRouting.UnconnectedSend"/>.
/// </summary>
/// <remarks>
/// logixpccc framing is UNVERIFIED and differs from libplctag: libplctag uses PLC-5 addressing with
/// 0x68/0x67 for LGX_PCCC, while this runtime sends SLC typed 0xA2/0xAA (no capture exists either way).
/// plc5 is likewise unverified beyond N-file reads. Neither family should default to Native until proven.
/// </remarks>
internal static CipRouting RoutingFor(string plcAttribute) => plcAttribute switch
{
"logixpccc" => CipRouting.UnconnectedSend,
_ => CipRouting.Direct,
};
internal static DeviceKey KeyFor(AbLegacyTagCreateParams p) =>
new(p.Gateway, p.Port > 0 ? p.Port : DefaultPort, p.CipPath, p.LibplctagPlcAttribute);
internal static ConnectionOptions OptionsFor(AbLegacyTagCreateParams p)
{
var timeout = p.Timeout > TimeSpan.Zero && p.Timeout != Timeout.InfiniteTimeSpan
? p.Timeout
: TimeSpan.FromSeconds(5);
return new ConnectionOptions
{
Routing = RoutingFor(p.LibplctagPlcAttribute),
ConnectionSize = 504,
AllowPacking = false, // PCCC requests are never MSP-packed
Timeout = timeout,
};
}
private static PcccAddress ToPcccAddress(string tagName)
{
var a = AbLegacyAddress.TryParse(tagName)
?? throw new AbTransportException(AbStatus.Unsupported, $"'{tagName}' is not a valid PCCC address.");
return a.SubElement is not null
? PcccAddress.WithSubElementName(a.FileLetter, a.FileNumber, a.WordNumber, a.SubElement)
: new PcccAddress(a.FileLetter, a.FileNumber, a.WordNumber, null, a.BitIndex);
}
private static int ElementSize(PcccAddress a) => a.FileLetter switch
{
"F" or "L" => 4,
"ST" => StringElementBytes,
"T" or "C" or "R" => a.SubElement is null ? 6 : 2,
_ => 2,
};
/// <inheritdoc />
public Task InitializeAsync(CancellationToken cancellationToken) =>
// libplctag resolves the tag on create and reports NotFound for a missing address; one read does the same.
RunAsync(() => ReadCoreAsync(cancellationToken));
/// <inheritdoc />
public Task ReadAsync(CancellationToken cancellationToken) => RunAsync(() => ReadCoreAsync(cancellationToken));
/// <inheritdoc />
public Task WriteAsync(CancellationToken cancellationToken) => RunAsync(() => WriteCoreAsync(cancellationToken));
/// <inheritdoc />
public int GetStatus() => Volatile.Read(ref _status);
private async Task RunAsync(Func<Task> op)
{
try
{
await op().ConfigureAwait(false);
Volatile.Write(ref _status, (int)AbStatus.Ok);
}
catch (AbTransportException ex)
{
Volatile.Write(ref _status, (int)ex.Status);
throw;
}
catch (OperationCanceledException)
{
throw;
}
catch
{
Volatile.Write(ref _status, (int)AbStatus.BadConnection);
throw;
}
}
private (int ChunkElements, PcccAddress Address) Plan()
{
var address = _address ?? throw _addressError!;
if (_count > 1 && address.SubElement is not null)
throw new AbTransportException(AbStatus.Unsupported, "Arrays of a structured-element sub-element are not supported.");
return (Math.Max(1, PcccServices.MaxDataBytes / _elementSize), address);
}
private async Task<ReadOnlyMemory<byte>> ExchangeAsync(Func<ushort, byte[]> pccc, CancellationToken ct)
{
var tns = unchecked((ushort)Interlocked.Increment(ref s_tns));
var request = PcccServices.ExecutePccc(_vendor, _serial, pccc(tns));
var reply = await _scheduler.SendAsync(request, ct).ConfigureAwait(false);
return PcccServices.ParseReply(reply, tns, _requestorId);
}
private async Task ReadCoreAsync(CancellationToken ct)
{
var (chunk, address) = Plan();
var plc5 = _plc == "plc5";
if (plc5 && (!PcccServices.Plc5Verified || address.FileLetter != "N"))
throw new AbTransportException(AbStatus.Unsupported, "PLC-5 reads are only verified for N files.");
var staged = new byte[_buffer.Length];
for (var done = 0; done < _count; done += chunk)
{
var n = Math.Min(chunk, _count - done);
var bytes = n * _elementSize;
var at = address with { Element = address.Element + done };
var data = await ExchangeAsync(
tns => plc5
? PcccServices.Plc5RangeRead(tns, at, (ushort)(bytes / 2), bytes)
: PcccServices.SlcTypedRead(tns, at, bytes),
ct).ConfigureAwait(false);
if (data.Length < bytes)
throw new AbTransportException(AbStatus.BadReply, $"PCCC read returned {data.Length} of {bytes} bytes.");
if (data.Length > bytes)
throw new AbTransportException(AbStatus.TooLarge, $"PCCC read returned {data.Length} bytes, more than the {bytes} requested.");
data.Span[..bytes].CopyTo(staged.AsSpan(done * _elementSize));
}
// Only a fully successful read replaces the buffer: a failed chunk never leaves a half-updated value.
staged.CopyTo(_buffer, 0);
}
private async Task WriteCoreAsync(CancellationToken ct)
{
if (_plc == "plc5")
throw new AbTransportException(AbStatus.Unsupported, "PLC-5 writes are not capture-verified.");
// libplctag does a masked bit write (0xAB) here; there is no capture of it, and a read-modify-write
// would race the PLC updating TT/DN. Writing the word instead would clobber EN/TT/DN, so refuse
// before anything reaches the wire.
if (StatusBit is not null)
throw new AbTransportException(AbStatus.Unsupported,
"Writes to a status-bit sub-element (e.g. T4:0.DN) are not supported by the native transport.");
var (chunk, address) = Plan();
for (var done = 0; done < _count; done += chunk)
{
var n = Math.Min(chunk, _count - done);
var bytes = n * _elementSize;
var at = address with { Element = address.Element + done };
var slice = _buffer.AsMemory(done * _elementSize, bytes);
// A later chunk failing throws: a partial write is reported as failed, never Good.
await ExchangeAsync(tns => PcccServices.SlcTypedWrite(tns, at, slice.Span), ct).ConfigureAwait(false);
}
}
private ReadOnlySpan<byte> Slice(int offset, int length)
{
if (offset < 0 || offset + length > _buffer.Length)
throw new AbTransportException(AbStatus.OutOfBounds, $"Buffer access {offset}+{length} exceeds {_buffer.Length} bytes.");
return _buffer.AsSpan(offset, length);
}
private Span<byte> SliceW(int offset, int length)
{
if (offset < 0 || offset + length > _buffer.Length)
throw new AbTransportException(AbStatus.OutOfBounds, $"Buffer access {offset}+{length} exceeds {_buffer.Length} bytes.");
return _buffer.AsSpan(offset, length);
}
private short GetInt16(int offset) => BinaryPrimitives.ReadInt16LittleEndian(Slice(offset, 2));
private int GetInt32(int offset) => BinaryPrimitives.ReadInt32LittleEndian(Slice(offset, 4));
private float GetFloat32(int offset) => BinaryPrimitives.ReadSingleLittleEndian(Slice(offset, 4));
/// <summary>The bit of a status-bit sub-element (T4:0.DN, C5:0.CU, R6:0.EN...), else null.</summary>
private int? StatusBit => _address is { SubElement: not null, Bit: int b } ? b : null;
private bool GetBit(int bit) => bit < 16
? (((ushort)GetInt16(0) >> bit) & 1) != 0
: (((uint)GetInt32(0) >> bit) & 1) != 0;
/// <inheritdoc />
public object? DecodeValue(AbLegacyDataType type, int? bitIndex)
{
// A status-bit sub-element is a BIT tag in libplctag: every scalar getter returns that bit (0/1).
if (StatusBit is int sb && type != AbLegacyDataType.String)
{
var v = GetBit(sb);
return type switch
{
AbLegacyDataType.Bit => v,
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long
or AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
or AbLegacyDataType.ControlElement => v ? 1 : 0,
AbLegacyDataType.Float => v ? 1f : 0f,
_ => null,
};
}
return type switch
{
AbLegacyDataType.Bit => bitIndex is int bit ? GetBit(bit) : (GetInt16(0) & 1) != 0,
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => (int)GetInt16(0),
AbLegacyDataType.Long => GetInt32(0),
AbLegacyDataType.Float => GetFloat32(0),
AbLegacyDataType.String => DecodeString(),
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
or AbLegacyDataType.ControlElement => GetInt32(0),
_ => null,
};
}
// SLC ST element: u16 LEN, then 82 data bytes with each byte pair swapped.
private string DecodeString()
{
var len = Math.Clamp((int)(ushort)GetInt16(0), 0, StringDataBytes);
var chars = new char[len];
for (var i = 0; i < len; i++) chars[i] = (char)Slice(2 + (i ^ 1), 1)[0];
return new string(chars);
}
private void SetString(string s)
{
if (s.Length > StringDataBytes)
throw new AbTransportException(AbStatus.TooLarge, $"String of {s.Length} chars exceeds {StringDataBytes}.");
var dest = SliceW(0, StringElementBytes);
dest.Clear();
BinaryPrimitives.WriteUInt16LittleEndian(dest, (ushort)s.Length);
for (var i = 0; i < s.Length; i++) dest[2 + (i ^ 1)] = (byte)s[i];
}
/// <inheritdoc />
public object? DecodeArray(AbLegacyDataType type, int count)
{
// Same layout assumptions as LibplctagLegacyTagRuntime.DecodeArray: element i at i * elementSize
// (2 for N/A, 4 for L/F), B files bit-packed (element i is bit i). String and structured arrays unsupported.
switch (type)
{
case AbLegacyDataType.Int:
case AbLegacyDataType.AnalogInt:
{
var arr = new short[count];
for (var i = 0; i < count; i++) arr[i] = GetInt16(i * 2);
return arr;
}
case AbLegacyDataType.Long:
{
var arr = new int[count];
for (var i = 0; i < count; i++) arr[i] = GetInt32(i * 4);
return arr;
}
case AbLegacyDataType.Float:
{
var arr = new float[count];
for (var i = 0; i < count; i++) arr[i] = GetFloat32(i * 4);
return arr;
}
case AbLegacyDataType.Bit:
{
var arr = new bool[count];
for (var i = 0; i < count; i++) arr[i] = ((Slice(i / 8, 1)[0] >> (i % 8)) & 1) != 0;
return arr;
}
default:
throw new NotSupportedException(
$"AbLegacyDataType {type} is not supported as an array element.");
}
}
/// <inheritdoc />
public void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value)
{
switch (type)
{
case AbLegacyDataType.Bit:
if (bitIndex is int)
throw new NotSupportedException(
"Bit-with-bitIndex writes must go through AbLegacyDriver.WriteBitInWordAsync.");
BinaryPrimitives.WriteInt16LittleEndian(SliceW(0, 2), Convert.ToBoolean(value) ? (short)1 : (short)0);
break;
case AbLegacyDataType.Int:
case AbLegacyDataType.AnalogInt:
BinaryPrimitives.WriteInt16LittleEndian(SliceW(0, 2), Convert.ToInt16(value));
break;
case AbLegacyDataType.Long:
BinaryPrimitives.WriteInt32LittleEndian(SliceW(0, 4), Convert.ToInt32(value));
break;
case AbLegacyDataType.Float:
BinaryPrimitives.WriteSingleLittleEndian(SliceW(0, 4), Convert.ToSingle(value));
break;
case AbLegacyDataType.String:
SetString(Convert.ToString(value) ?? string.Empty);
break;
case AbLegacyDataType.TimerElement:
case AbLegacyDataType.CounterElement:
case AbLegacyDataType.ControlElement:
BinaryPrimitives.WriteInt32LittleEndian(SliceW(0, 4), Convert.ToInt32(value));
break;
default:
throw new NotSupportedException($"AbLegacyDataType {type} not writable.");
}
}
/// <summary>Releases the lease on the shared device connection (the last release closes it).</summary>
public void Dispose()
{
if (_lease is null) return;
// Closing does I/O (Forward Close / UnRegisterSession); never block the caller on it.
_ = _lease.DisposeAsync().AsTask().ContinueWith(
static t => { _ = t.Exception; }, TaskContinuationOptions.OnlyOnFaulted);
}
}
@@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
</ItemGroup>
@@ -32,6 +32,15 @@
</div>
<div class="form-text">Only enable when UDT member declaration order matches controller compiled layout.</div>
</div>
<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 (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 node-level <code>Drivers:AbCip:DefaultTransport</code> picks (Native unless an operator pinned it); takes effect on the next deploy.</div>
</div>
</div>
</div>
</section>
@@ -136,7 +145,11 @@
/// Devices array forward (the device form authors the real endpoints; the merge reconstructs the array
/// from the device rows at deploy/probe time).</summary>
public string GetConfigJson()
=> JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
=> Serialize(_form.ToOptions(_preservedDevices));
/// <summary>Serializes options with the form's JSON settings (camelCase, enums as strings).</summary>
internal static string Serialize(AbCipDriverOptions options)
=> JsonSerializer.Serialize(options, _jsonOpts);
private async Task EmitAsync()
{
@@ -151,7 +164,7 @@
await ResilienceConfigChanged.InvokeAsync(r);
}
private static AbCipDriverOptions? TryDeserialize(string json)
internal static AbCipDriverOptions? TryDeserialize(string json)
{
try { return JsonSerializer.Deserialize<AbCipDriverOptions>(json, _jsonOpts); }
catch { return null; }
@@ -163,6 +176,10 @@
{
// Operation
public int TimeoutSeconds { get; set; } = 2;
// Null = "Default": the transport key is OMITTED on save so the driver resolves it (and the AbLegacy
// PLC-5 / LogixPccc exclusion still applies). Never re-emit a concrete default here — that would pin
// every edited driver to it.
public AbCipTransport? Transport { get; set; }
public bool EnableControllerBrowse { get; set; } = false;
public bool EnableDeclarationOnlyUdtGrouping { get; set; } = false;
@@ -182,6 +199,7 @@
public static FormModel FromOptions(AbCipDriverOptions o) => new()
{
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
Transport = o.Transport,
EnableControllerBrowse = o.EnableControllerBrowse,
EnableDeclarationOnlyUdtGrouping = o.EnableDeclarationOnlyUdtGrouping,
EnableAlarmProjection = o.EnableAlarmProjection,
@@ -205,6 +223,7 @@
ProbeTagPath = string.IsNullOrWhiteSpace(ProbeTagPath) ? null : ProbeTagPath,
},
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
Transport = Transport,
EnableControllerBrowse = EnableControllerBrowse,
EnableAlarmProjection = EnableAlarmProjection,
AlarmPollInterval = TimeSpan.FromSeconds(AlarmPollIntervalSeconds),
@@ -19,6 +19,15 @@
<InputNumber @bind-Value="_form.TimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default read/write timeout. Default 2 s.</div>
</div>
<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 (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 (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>
</section>
@@ -101,7 +110,11 @@
/// <summary>Serializes the current channel/protocol config to camelCase JSON, preserving the driver's
/// existing Devices array (authored as separate v3 entities).</summary>
public string GetConfigJson()
=> JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
=> Serialize(_form.ToOptions(_preservedDevices));
/// <summary>Serializes options with the form's JSON settings (camelCase, enums as strings).</summary>
internal static string Serialize(AbLegacyDriverOptions options)
=> JsonSerializer.Serialize(options, _jsonOpts);
private async Task EmitAsync()
{
@@ -116,7 +129,7 @@
await ResilienceConfigChanged.InvokeAsync(r);
}
private static AbLegacyDriverOptions? TryDeserialize(string json)
internal static AbLegacyDriverOptions? TryDeserialize(string json)
{
try { return JsonSerializer.Deserialize<AbLegacyDriverOptions>(json, _jsonOpts); }
catch { return null; }
@@ -128,6 +141,10 @@
{
// Operation
public int TimeoutSeconds { get; set; } = 2;
// Null = "Default": the transport key is OMITTED on save so the driver resolves it (and the AbLegacy
// PLC-5 / LogixPccc exclusion still applies). Never re-emit a concrete default here — that would pin
// every edited driver to it.
public AbLegacyTransport? Transport { get; set; }
// Probe
public bool ProbeEnabled { get; set; } = true;
@@ -145,6 +162,7 @@
public static FormModel FromOptions(AbLegacyDriverOptions o) => new()
{
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
Transport = o.Transport,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
@@ -164,6 +182,7 @@
ProbeAddress = string.IsNullOrWhiteSpace(ProbeAddress) ? null : ProbeAddress,
},
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
Transport = Transport,
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
};
}
@@ -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);
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();
@@ -0,0 +1,66 @@
using CliFx;
using CliFx.Attributes;
using CliFx.Infrastructure;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests;
/// <summary>The CLI <c>--transport</c> flag reaches <see cref="AbCipDriverOptions.Transport"/>.</summary>
[Trait("Category", "Unit")]
public sealed class AbCipTransportOptionTests
{
[Command("capture-transport")]
private sealed class CapturingCommand : AbCipCommandBase
{
public static AbCipTransport? Captured;
public static bool Ran;
public override ValueTask ExecuteAsync(IConsole console)
{
Ran = true;
Captured = BuildOptions([]).Transport;
return default;
}
}
/// <summary>
/// With no flag the CLI leaves the option null ("use the default"), and the driver's resolver makes
/// that Native — the same path an absent <c>transport</c> key takes in a deployed config.
/// </summary>
[Fact]
public async Task Transport_defaults_to_the_resolver_default_native()
{
CapturingCommand.Captured = null;
CapturingCommand.Ran = false;
using var console = new FakeInMemoryConsole();
var app = new CliApplicationBuilder().AddCommand(typeof(CapturingCommand)).UseConsole(console).Build();
var exit = await app.RunAsync(["capture-transport", "--gateway", "ab://1.2.3.4/1,0"]);
exit.ShouldBe(0, console.ReadErrorString());
CapturingCommand.Ran.ShouldBeTrue();
CapturingCommand.Captured.ShouldBeNull();
AbCipTransportResolver.Resolve(CapturingCommand.Captured).ShouldBe(AbCipTransport.Native);
}
/// <summary>Verifies CliFx parses <c>--transport</c> (case-insensitive) into the options.</summary>
[Theory]
[InlineData("native", AbCipTransport.Native)]
[InlineData("Native", AbCipTransport.Native)]
[InlineData("libplctag", AbCipTransport.Libplctag)]
[InlineData("default", null)]
public async Task Transport_flag_is_parsed_by_the_cli(string value, AbCipTransport? expected)
{
CapturingCommand.Ran = false;
CapturingCommand.Captured = null;
using var console = new FakeInMemoryConsole();
var app = new CliApplicationBuilder().AddCommand(typeof(CapturingCommand)).UseConsole(console).Build();
var exit = await app.RunAsync(["capture-transport", "--gateway", "ab://1.2.3.4/1,0", "--transport", value]);
exit.ShouldBe(0, console.ReadErrorString());
CapturingCommand.Ran.ShouldBeTrue();
CapturingCommand.Captured.ShouldBe(expected);
}
}
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using CliFx.Infrastructure;
using Shouldly;
@@ -158,4 +160,82 @@ public sealed class BuildOptionsTests
options.Devices.Count.ShouldBe(1);
options.Devices[0].PlcFamily.ShouldBe(AbLegacyPlcFamily.Plc5);
}
/// <summary>
/// With no <c>--transport</c> the CLI leaves the option null and the driver's resolver decides: libplctag
/// for every family (final review I3; Native is opt-in). An explicit choice is forwarded verbatim and wins.
/// </summary>
[Theory]
[InlineData(AbLegacyPlcFamily.Slc500, AbLegacyTransport.Libplctag)]
[InlineData(AbLegacyPlcFamily.MicroLogix, AbLegacyTransport.Libplctag)]
[InlineData(AbLegacyPlcFamily.Plc5, AbLegacyTransport.Libplctag)]
[InlineData(AbLegacyPlcFamily.LogixPccc, AbLegacyTransport.Libplctag)]
public void BuildOptions_default_transport_goes_through_the_resolver(AbLegacyPlcFamily family, AbLegacyTransport expected)
{
var options = new TestCommand { Gateway = "ab://192.168.1.20/1,0", PlcType = family }.Build([]);
options.Transport.ShouldBeNull();
AbLegacyTransportResolver.Resolve(options).ShouldBe(expected);
var native = new TestCommand
{
Gateway = "ab://192.168.1.20/1,0", PlcType = family, Transport = AbLegacyCliTransport.Native,
}.Build([]);
native.Transport.ShouldBe(AbLegacyTransport.Native);
AbLegacyTransportResolver.Resolve(native).ShouldBe(AbLegacyTransport.Native);
}
/// <summary>
/// End to end through CliFx: no <c>--transport</c> resolves to libplctag for every <c>--plc-type</c>
/// (final review I3). The slc500 / micrologix rows are the falsification rows — they resolved Native
/// before the revert.
/// </summary>
[Theory]
[InlineData("plc5", AbLegacyTransport.Libplctag)]
[InlineData("logixpccc", AbLegacyTransport.Libplctag)]
[InlineData("slc500", AbLegacyTransport.Libplctag)]
[InlineData("micrologix", AbLegacyTransport.Libplctag)]
public async Task Plc_type_without_transport_flag_resolves_through_the_resolver(string plcType, AbLegacyTransport expected)
{
CapturingCommand.Options = null;
using var console = new FakeInMemoryConsole();
var app = new CliApplicationBuilder().AddCommand(typeof(CapturingCommand)).UseConsole(console).Build();
var exit = await app.RunAsync(["capture-transport", "--gateway", "ab://1.2.3.4/1,0", "--plc-type", plcType]);
exit.ShouldBe(0, console.ReadErrorString());
CapturingCommand.Options.ShouldNotBeNull();
CapturingCommand.Options.Transport.ShouldBeNull();
AbLegacyTransportResolver.Resolve(CapturingCommand.Options).ShouldBe(expected);
}
/// <summary>Verifies CliFx really parses <c>--transport</c> (case-insensitive) into the property.</summary>
[Theory]
[InlineData("native", AbLegacyTransport.Native)]
[InlineData("Native", AbLegacyTransport.Native)]
[InlineData("libplctag", AbLegacyTransport.Libplctag)]
[InlineData("default", null)]
public async Task Transport_flag_is_parsed_by_the_cli(string value, AbLegacyTransport? expected)
{
CapturingCommand.Options = null;
using var console = new FakeInMemoryConsole();
var app = new CliApplicationBuilder().AddCommand(typeof(CapturingCommand)).UseConsole(console).Build();
var exit = await app.RunAsync(["capture-transport", "--gateway", "ab://1.2.3.4/1,0", "--transport", value]);
exit.ShouldBe(0, console.ReadErrorString());
CapturingCommand.Options.ShouldNotBeNull();
CapturingCommand.Options.Transport.ShouldBe(expected);
}
[Command("capture-transport")]
private sealed class CapturingCommand : AbLegacyCommandBase
{
public static AbLegacyDriverOptions? Options;
public override System.Threading.Tasks.ValueTask ExecuteAsync(IConsole console)
{
Options = BuildOptions([]);
return default;
}
}
}
@@ -0,0 +1,53 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests;
public sealed class AbStatusCodesTests
{
[Theory]
[InlineData((byte)0x00, AbStatus.Ok)]
[InlineData((byte)0x04, AbStatus.NotFound)]
[InlineData((byte)0x05, AbStatus.NotFound)]
[InlineData((byte)0x16, AbStatus.NotFound)]
[InlineData((byte)0x06, AbStatus.Partial)]
[InlineData((byte)0x08, AbStatus.Unsupported)]
[InlineData((byte)0x0F, AbStatus.NotAllowed)]
[InlineData((byte)0x10, AbStatus.DeviceStateConflict)]
[InlineData((byte)0x13, AbStatus.TooSmall)]
[InlineData((byte)0x15, AbStatus.TooLarge)]
[InlineData((byte)0x0B, AbStatus.Ok)]
[InlineData((byte)0x0A, AbStatus.OutOfBounds)]
[InlineData((byte)0x0C, AbStatus.DeviceStateConflict)]
[InlineData((byte)0x0E, AbStatus.NotAllowed)]
[InlineData((byte)0x1E, AbStatus.BadReply)]
[InlineData((byte)0x01, AbStatus.BadReply)]
public void Cip_general_status_maps(byte general, AbStatus expected) =>
AbStatusCodes.FromCip(general, ReadOnlySpan<ushort>.Empty).ShouldBe(expected);
[Theory]
[InlineData((ushort)0x2105, AbStatus.OutOfBounds)]
[InlineData((ushort)0x2107, AbStatus.TypeMismatch)]
[InlineData((ushort)0x9999, AbStatus.BadReply)]
public void General_status_FF_maps_by_extended_status(ushort ext, AbStatus expected) =>
AbStatusCodes.FromCip(0xFF, [ext]).ShouldBe(expected);
[Theory]
[InlineData((byte)0x00, null, AbStatus.Ok)]
[InlineData((byte)0x10, null, AbStatus.Unsupported)]
[InlineData((byte)0x20, null, AbStatus.NotFound)]
[InlineData((byte)0x30, null, AbStatus.NotAllowed)]
[InlineData((byte)0x40, null, AbStatus.DeviceStateConflict)]
[InlineData((byte)0xF0, (byte)0x06, AbStatus.NotFound)]
[InlineData((byte)0xF0, (byte)0x07, AbStatus.NotFound)]
[InlineData((byte)0xF0, (byte)0x0A, AbStatus.BadReply)]
public void Pccc_status_maps(byte sts, byte? ext, AbStatus expected) =>
AbStatusCodes.FromPccc(sts, ext).ShouldBe(expected);
[Fact]
public void Ok_is_zero_and_every_error_is_negative()
{
((int)AbStatus.Ok).ShouldBe(0);
foreach (var s in Enum.GetValues<AbStatus>().Where(s => s is not AbStatus.Ok and not AbStatus.Pending))
((int)s).ShouldBeLessThan(0);
}
}
@@ -0,0 +1,48 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
/// <summary>One EtherNet/IP encapsulation frame from a golden capture.</summary>
/// <param name="FromClient"><c>true</c> for a client→server (<c>&gt;</c>) frame.</param>
/// <param name="Bytes">The full frame: 24-byte encapsulation header plus data.</param>
internal sealed record CapturedFrame(bool FromClient, byte[] Bytes);
/// <summary>
/// Loads the golden libplctag ⇄ ab_server captures in <c>Captures/*.hex</c>. See
/// <c>Captures/README.md</c> for how each was taken.
/// </summary>
internal static class CaptureFile
{
/// <summary>The directory the <c>.hex</c> fixtures are copied to.</summary>
public static string Dir => Path.Combine(AppContext.BaseDirectory, "Captures");
/// <summary>Loads every frame of a capture, in wire order; <c>#</c> lines are skipped.</summary>
/// <param name="name">The capture's file name, e.g. <c>logix-read-dint.hex</c>.</param>
/// <returns>The frames.</returns>
public static IReadOnlyList<CapturedFrame> Load(string name) =>
File.ReadAllLines(Path.Combine(Dir, name))
.Where(l => l.Length > 2 && l[0] is '>' or '<')
.Select(l => new CapturedFrame(l[0] == '>', Convert.FromHexString(l[2..].Trim())))
.ToList();
/// <summary>The CIP payload of a SendRRData / SendUnitData frame (the last CPF item's data,
/// minus the 2-byte sequence count for connected data).</summary>
/// <param name="f">The frame.</param>
/// <returns>The CIP payload, or empty for any other encapsulation command.</returns>
public static byte[] CipPayload(CapturedFrame f)
{
var b = f.Bytes;
var cmd = BitConverter.ToUInt16(b, 0); // test-only; fixtures are LE on a LE host
if (cmd is not (0x6F or 0x70)) return []; // only SendRRData / SendUnitData carry CPF items
var pos = 24 + 4 + 2; // header + interface handle + timeout
var itemCount = BitConverter.ToUInt16(b, pos); pos += 2;
byte[] last = [];
for (var i = 0; i < itemCount; i++)
{
var type = BitConverter.ToUInt16(b, pos);
var len = BitConverter.ToUInt16(b, pos + 2);
last = b.AsSpan(pos + 4, len).ToArray();
if (type == 0x00B1) last = last[2..]; // strip connected sequence count
pos += 4 + len;
}
return last;
}
}
@@ -0,0 +1,104 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
public sealed class CaptureFileTests
{
private static readonly string[] Required =
[
"logix-register-session.hex", "logix-forward-open.hex",
"logix-read-dint.hex", "logix-read-dint-array16.hex", "logix-read-string.hex", "logix-read-bool.hex",
"logix-write-dint.hex", "logix-write-string.hex", "logix-read-unknown-tag.hex",
"logix-tags-listing.hex", "logix-forward-close.hex", "micro800-read-dint.hex",
"slc500-read-n7.hex", "slc500-write-n7.hex", "slc500-read-f8.hex",
"micrologix-read-n7.hex", "plc5-read-n7.hex",
];
public static TheoryData<string> AllCaptures()
{
var data = new TheoryData<string>();
foreach (var f in Directory.GetFiles(CaptureFile.Dir, "*.hex").Order()) data.Add(Path.GetFileName(f));
return data;
}
[Fact]
public void Every_required_capture_is_present() =>
Required.Where(n => !File.Exists(Path.Combine(CaptureFile.Dir, n))).ShouldBeEmpty();
[Theory]
[MemberData(nameof(AllCaptures))]
public void Every_frame_length_field_matches_its_data_length(string name)
{
var frames = CaptureFile.Load(name);
frames.ShouldNotBeEmpty();
foreach (var f in frames)
{
f.Bytes.Length.ShouldBeGreaterThanOrEqualTo(24, name);
BitConverter.ToUInt16(f.Bytes, 2).ShouldBe((ushort)(f.Bytes.Length - 24), name);
}
}
[Theory]
[MemberData(nameof(AllCaptures))]
public void Frames_alternate_request_then_reply(string name)
{
var frames = CaptureFile.Load(name);
frames.Count.ShouldBe(frames.Count / 2 * 2, name);
for (var i = 0; i < frames.Count; i++) frames[i].FromClient.ShouldBe(i % 2 == 0, $"{name} frame {i}");
}
[Theory]
[MemberData(nameof(AllCaptures))]
public void Every_CPF_item_walk_ends_exactly_at_the_frame_end(string name)
{
foreach (var f in CaptureFile.Load(name).Where(f => BitConverter.ToUInt16(f.Bytes, 0) is 0x6F or 0x70))
{
var pos = 24 + 4 + 2;
var itemCount = BitConverter.ToUInt16(f.Bytes, pos);
pos += 2;
for (var i = 0; i < itemCount; i++) pos += 4 + BitConverter.ToUInt16(f.Bytes, pos + 2);
pos.ShouldBe(f.Bytes.Length, name);
}
}
[Theory]
[MemberData(nameof(AllCaptures))]
public void Every_reply_service_is_its_request_service_with_the_reply_bit(string name)
{
var frames = CaptureFile.Load(name);
for (var i = 0; i + 1 < frames.Count; i += 2)
{
var request = CaptureFile.CipPayload(frames[i]);
var reply = CaptureFile.CipPayload(frames[i + 1]);
if (request.Length == 0) continue; // RegisterSession: no CIP
reply[0].ShouldBe((byte)(request[0] | 0x80), $"{name} pair {i / 2}");
}
}
[Fact]
public void CipPayload_strips_the_connected_sequence_count()
{
// Connected Read Tag Fragmented of TestDINT: service 0x52, path size 5 words, symbol 0x91 len 8.
var request = CaptureFile.Load("logix-read-dint.hex")[0];
CaptureFile.CipPayload(request)[..4].ShouldBe(new byte[] { 0x52, 0x05, 0x91, 0x08 });
}
[Fact]
public void CipPayload_returns_the_unconnected_item_for_SendRRData()
{
// Large Forward Open request goes out in an unconnected data item (0xB2): service 0x5B.
var request = CaptureFile.Load("logix-forward-open.hex")[0];
CaptureFile.CipPayload(request)[0].ShouldBe((byte)0x5B);
}
[Fact]
public void CipPayload_is_empty_for_RegisterSession() =>
CaptureFile.CipPayload(CaptureFile.Load("logix-register-session.hex")[0]).ShouldBeEmpty();
}
@@ -0,0 +1,134 @@
# Golden frame captures — libplctag ⇄ ab_server
These `.hex` files are the exact EtherNet/IP frames libplctag exchanged with the `ab_server`
simulator. The native AB transport's codecs (plan Tasks 7 and 8) assert **byte parity** against
them.
> ⚠️ They prove parity with **libplctag**, not correctness against a controller. The independent
> reference is the real-press captures in
> `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectRealControllerCaptureTests.cs`
> and `CipTemplateObjectWireCaptureTests.cs`.
## Format
One frame per line: `> ` = client→server, `< ` = server→client, then the full encapsulation
frame (24-byte header + data) as uppercase hex with no spaces. `#` lines are comments; each file
opens with a comment block naming its client, server, profile, tag string and what the frames are.
`CaptureFile.Load` reads them; `CaptureFileTests` checks every frame's header length field
against its data length and that requests and replies alternate.
Values are **little-endian on the wire** and carry live per-session values — session handle,
connection IDs, connection serial, PCCC TNS. A parity test must compare structure and the
fields it builds, not those session-assigned values.
### Simulator behaviour that is NOT controller behaviour
- **ab_server zeroes the reply sender context.** libplctag sends a non-zero 8-byte sender context
on every SendRRData request (e.g. `45A0091C00000000` in `logix-forward-open.hex`), and every
ab_server reply carries `0000000000000000`. A real target echoes the request's context.
Consequences: parity tests must **exclude** the sender-context field (header bytes 12–19), and
the native transport **must not depend on the echo** to correlate replies, or it will pass
against a real controller and fail against the sim, or the reverse.
- **ab_server types a STRING as atomic `D0 00`, not as a structure.** A STRING read reply (see
`logix-read-string.hex`) is `D2 00 00 00` + type `D0 00` + 88 data bytes: a 4-byte `LEN`
(`07 00 00 00` for `"HelloAB"`), the 82-byte `DATA` field, and 2 pad bytes. Write requests
(`logix-write-string.hex`) echo the same `D0 00` type. A real Logix STRING is a structure:
type `A0 02` followed by the 2-byte structure handle, then the same 88-byte body. Tasks 7 and 8
should treat the `D0 00` type as simulator behaviour. Byte parity on the type field holds only
against this sim, and a STRING codec must accept the `A0 02` + handle form from a controller.
- **ab_server refuses libplctag's BOOL write.** libplctag sends two data bytes (`01 00`) for a
one-byte BOOL, and ab_server's "too much data" check (`cip.c` in `handle_write_request`) answers
CIP 0x03. That is why there is no BOOL write fixture. `logix-read-bool.hex` was seeded by a raw
UCMM Write Tag carrying one data byte (`client seed-bool`, below). Incidentally, that write shows
ab_server accepts an Unconnected Send (0x52) routed `01 00` in ControlLogix mode.
## How they were taken
| | |
|---|---|
| Date | 2026-10-07 |
| Repo commit | `a0897fda` (fixture sources identical to the files on the docker host, checked by md5) |
| Client | **libplctag 1.5.2** (libplctag.NET NuGet, `Directory.Packages.props`) → libplctag.NativeImport 1.0.41 → native core **2.6.0** (read via `plc_tag_get_int_attribute(0,"version_*")`) |
| Server image | **`otopcua-ab-server:libplctag-release`** (ab_server from libplctag `v2.7.0` + vendored patches 0001 `@tags` listing and 0002 tag-not-found → CIP 0x04; both confirmed live — see `logix-tags-listing.hex` / `logix-read-unknown-tag.hex`) |
| AbCip compose | `/opt/otopcua-abcip` on `10.100.0.35` (= `tests/.../AbCip.IntegrationTests/Docker/`), host `:44818` |
| AbLegacy compose | `~/otopcua-ablegacy` on `10.100.0.35` (= `tests/.../AbLegacy.IntegrationTests/Docker/`), host `:44819` slc500 / `:44820` micrologix / `:44821` plc5 |
| Capture point | `tcpdump -i eth0 tcp port 44818` in a `nicolaka/netshoot:v0.13` sidecar sharing the simulator container's network namespace; `tshark` 4.2.4 in the same image; segments re-joined into frames by the 24-byte header length field |
| Recipe | `scripts/ab-capture/capture.sh run <container> <name> -- scripts/ab-capture/capture.sh client …`, then `capture.sh tohex …` |
| Client | `scripts/ab-capture/client/` (`ab-capture-client.csproj`). Not in the solution; pins libplctag 1.5.2 with CPM off. `capture.sh client …` builds it once and runs it |
The client (`scripts/ab-capture/client/Program.cs`) is a small console program over libplctag 1.5.2 that mirrors how
`LibplctagTagRuntime` / `LibplctagLegacyTagRuntime` / `LibplctagTagEnumerator` build a tag
(`Gateway`, `Path`, `PlcType`, `Protocol=ab_eip`, `Name`, `Timeout=5s`, `ElementCount` when > 1;
no other attributes), then runs `InitializeAsync` + one `ReadAsync` or `WriteAsync`, disposes the
tag and calls `plc_tag_shutdown` so the Forward Close reaches the wire. **One fresh session per
capture**, one profile up at a time. Note that `InitializeAsync` itself performs a read, so a
"read" capture holds two read exchanges and a "write" capture holds a read then the write.
Values were seeded before the reads so that no decode oracle is all zeros. Seeding commands
(they were not captured):
```bash
C=scripts/ab-capture/capture.sh
$C client write 10.100.0.35:44818 1,0 ControlLogix TestDINT dint 1 123456
$C client write 10.100.0.35:44818 1,0 ControlLogix TestString string 1 HelloAB
$C client write 10.100.0.35:44818 1,0 ControlLogix TestArray dintseq 16 x # element i (0-based) = ((i+1)<<24)|(i+1)
$C client seed-bool 10.100.0.35 44818 TestBOOL 1 # raw UCMM, see above
```
The `logix-write-*` captures are themselves the seeds for TestDINT and TestString. Each read
capture is then, for example:
```bash
CAPDIR=<fresh dir> $C run otopcua-ab-server-controllogix logix-read-dint-array16 -- \
$C client read 10.100.0.35:44818 1,0 ControlLogix TestArray raw 16
$C tohex <fresh dir>/logix-read-dint-array16.pcap logix-read-dint-array16.full.hex
```
### Per-file record
All files: libplctag 1.5.2 (native 2.6.0), image `otopcua-ab-server:libplctag-release`. The
tag string is the libplctag attribute set the wrapper was given.
| File | Profile | Tag string | Frames |
|---|---|---|---|
| `logix-register-session.hex` | controllogix | `protocol=ab_eip&gateway=10.100.0.35:44818&path=1,0&plc=controllogix&name=TestDINT` | RegisterSession (frames 1–2 of the `logix-read-dint` session) |
| `logix-forward-open.hex` | controllogix | same as above | Large Forward Open **0x5B** (frames 3–4) |
| `logix-read-dint.hex` | controllogix | `…&plc=controllogix&name=TestDINT` | 2× connected Read Tag Fragmented 0x52 (value 123456) |
| `logix-read-dint-array16.hex` | controllogix | `…&name=TestArray&elem_count=16` | 2× 0x52, 16 elements; element i (0-based) = `((i+1)<<24)\|(i+1)`, i.e. `0x01000001` … `0x10000010` (LE on the wire: `01 00 00 01`, `02 00 00 02` …) |
| `logix-read-string.hex` | controllogix | `…&name=TestString` | 2× 0x52, STRING `"HelloAB"` |
| `logix-read-bool.hex` | controllogix | `…&name=TestBOOL` | 2× 0x52, BOOL **1** (seeded via `client seed-bool`) |
| `logix-write-dint.hex` | controllogix | `…&name=TestDINT` | 0x52 read, then Write Tag 0x4D of 123456 |
| `logix-write-string.hex` | controllogix | `…&name=TestString` | 0x52 read, then 0x4D of `"HelloAB"` |
| `logix-read-unknown-tag.hex` | controllogix | `…&name=NoSuchTag` | 0x52 → general status **0x04** (patch 0002); libplctag raised `ErrorNotFound`, no retry |
| `logix-tags-listing.hex` | controllogix | `…&name=@tags` | Get Instance Attribute List **0x55** on Symbol class 0x6B (patch 0001); all 9 tags in one reply, status 0x00 |
| `logix-forward-close.hex` | controllogix | `…&name=TestDINT` | Forward Close 0x4E (last 2 frames of the `logix-read-dint` session) |
| `micro800-read-dint.hex` | micro800 | `protocol=ab_eip&gateway=10.100.0.35:44818&plc=micro800&name=TestDINT` | **Full session**: RegisterSession, Large FO, 2× 0x52 (value 987654), Forward Close |
| `slc500-read-n7.hex` | slc500 | `protocol=ab_eip&gateway=10.100.0.35:44819&path=1,0&plc=slc500&name=N7:0` | Full session: RegisterSession, 2× Execute PCCC 0x4B / PCCC 0x0F-0xA2 (value 4321) |
| `slc500-write-n7.hex` | slc500 | same | Full session: 0x0F-0xA2 read, then 0x0F-0xAA write of 4321 |
| `slc500-read-f8.hex` | slc500 | `…:44819&path=1,0&plc=slc500&name=F8:0` | Full session: 2× 0x0F-0xA2 (value 3.5) |
| `micrologix-read-n7.hex` | micrologix | `protocol=ab_eip&gateway=10.100.0.35:44820&plc=micrologix&name=N7:0` | Full session: 2× 0x0F-0xA2 (value 2468) |
| `plc5-read-n7.hex` | plc5 | `protocol=ab_eip&gateway=10.100.0.35:44821&path=1,0&plc=plc5&name=N7:0` | Full session: 2× PCCC 0x0F-**0x01** PLC-5 typed read (value 1357) |
Logix files other than `register-session`/`forward-open`/`forward-close` hold **only the
operation's exchanges**; the handshake frames of their sessions are byte-for-byte the same shape
as those three files. The Micro800 and PCCC files hold the **whole session** so their routing is
visible in one place. No session in any family sent UnregisterSession (0x66); each ends with a TCP RST after the last reply.
## Routing observed
What libplctag 2.6.0 actually put on the wire. Tasks 9, 12 and 14 take their `Routing` choice
from this table.
| Family | ForwardOpen | CIP request wrapped in Unconnected Send (0x52) | Route bytes | Requestor-id bytes (PCCC) |
|---|---|---|---|---|
| controllogix (`path=1,0`) | **yes** — Large FO 0x5B only (0x54 never sent; the 0x5B succeeded). FO itself is sent direct to the Connection Manager `20 06 24 01`, not wrapped. Connection path `01 00 20 02 24 01` (3 words), net params `0x42000FA2`, RPI 1 000 000 µs, transport `0xA3` | **no** — requests ride the connection (SendUnitData, item 0xB1) | `01 00` (port 1, slot 0), carried only inside the FO connection path | n/a |
| micro800 (no path) | **yes** — Large FO 0x5B, connection path `20 02 24 01` (2 words, no port segment). ⚠️ Not UCMM; see concerns | **no** — connected | none | n/a |
| slc500 (`path=1,0`) | **no** | **no** — Execute PCCC 0x4B sent unconnected (SendRRData, item 0xB2) straight to `20 67 24 01` (PCCC object) | **none — the `1,0` path was given but never appeared on the wire** | `07 3D F3 45 43 50 21` (length 7, vendor 0xF33D, serial 0x21504345) |
| micrologix (no path) | **no** | **no** — as slc500 | none | `07 3D F3 45 43 50 21` |
| plc5 (`path=1,0`) | **no** | **no** — as slc500 | **none — `1,0` not emitted** | `07 3D F3 45 43 50 21` |
| logixpccc (`path=1,0`, against the controllogix profile) | **no** | **yes** — Unconnected Send 0x52 to `20 06 24 01`, priority/tick `0A 05`, embedded 0x4B (len 0x1C) | `01 00` (path size 1 word, reserved, then `01 00`) | `07 3D F3 45 43 50 21` |
**logixpccc is not reachable**: ab_server's ControlLogix mode has no PCCC object and answers
`CB 00 08 00` (service not supported). Its request shape above is read from the client side of
that rejected exchange; **no `.hex` fixture was committed for it**, since a parity fixture whose
reply is an error would teach nothing about the success path.
@@ -0,0 +1,9 @@
# logix-forward-close.hex
# client : libplctag 1.5.2 (libplctag.NET wrapper; libplctag.NativeImport 1.0.41, native core 2.6.0)
# server : otopcua-ab-server:libplctag-release (ab_server from libplctag v2.7.0 + patches 0001/0002)
# profile: controllogix
# tag : protocol=ab_eip&gateway=10.100.0.35:44818&path=1,0&plc=controllogix&name=TestDINT
# session: logix-read-dint (captured 2026-10-07, scripts/ab-capture/capture.sh)
# frames : Forward Close 0x4E request/reply (last two frames of the logix-read-dint session). No UnregisterSession followed.
> 6F002800F3A5081A00000000F0B223110000000000000000000000000100020000000000B20018004E02200624010A05B1723DF3454350210300010020022401
< 6F001E00F3A5081A00000000000000000000000000000000000000000100020000000000B2000E00CE000000B1723DF3454350210000

Some files were not shown because too many files have changed in this diff Show More