diff --git a/CLAUDE.md b/CLAUDE.md index a11fb574..65a02ec7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 85b032b2..fd2ce393 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -40,6 +40,7 @@ + @@ -109,6 +110,7 @@ + diff --git a/docs/Configuration.md b/docs/Configuration.md index ceac456a..4f2d478b 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -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`) diff --git a/docs/drivers/AbCip.md b/docs/drivers/AbCip.md index 37db1507..6d7b7193 100644 --- a/docs/drivers/AbCip.md +++ b/docs/drivers/AbCip.md @@ -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= (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. diff --git a/docs/drivers/AbLegacy-Test-Fixture.md b/docs/drivers/AbLegacy-Test-Fixture.md index f537020b..2c7f459a 100644 --- a/docs/drivers/AbLegacy-Test-Fixture.md +++ b/docs/drivers/AbLegacy-Test-Fixture.md @@ -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 diff --git a/docs/drivers/AbLegacy.md b/docs/drivers/AbLegacy.md index 7cbcdfe6..20c251c7 100644 --- a/docs/drivers/AbLegacy.md +++ b/docs/drivers/AbLegacy.md @@ -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= (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 diff --git a/docs/drivers/AbServer-Test-Fixture.md b/docs/drivers/AbServer-Test-Fixture.md index 55c2f476..74a071e8 100644 --- a/docs/drivers/AbServer-Test-Fixture.md +++ b/docs/drivers/AbServer-Test-Fixture.md @@ -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) | diff --git a/docs/plans/2026-10-07-native-ab-transport-design.md b/docs/plans/2026-10-07-native-ab-transport-design.md new file mode 100644 index 00000000..23d180a7 --- /dev/null +++ b/docs/plans/2026-10-07-native-ab-transport-design.md @@ -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. diff --git a/docs/plans/2026-10-07-native-ab-transport-gate-results.md b/docs/plans/2026-10-07-native-ab-transport-gate-results.md new file mode 100644 index 00000000..5d81422c --- /dev/null +++ b/docs/plans/2026-10-07-native-ab-transport-gate-results.md @@ -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=

`; AbLegacy `~/otopcua-ablegacy`, +`AB_LEGACY_COMPOSE_PROFILE=

`); 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 | + +``` + diff --git a/docs/plans/2026-10-07-native-ab-transport-t21/t21-pass1.txt b/docs/plans/2026-10-07-native-ab-transport-t21/t21-pass1.txt new file mode 100644 index 00000000..1eff5ca3 --- /dev/null +++ b/docs/plans/2026-10-07-native-ab-transport-t21/t21-pass1.txt @@ -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 diff --git a/docs/plans/2026-10-07-native-ab-transport-t21/t21-pass2.txt b/docs/plans/2026-10-07-native-ab-transport-t21/t21-pass2.txt new file mode 100644 index 00000000..37b35275 --- /dev/null +++ b/docs/plans/2026-10-07-native-ab-transport-t21/t21-pass2.txt @@ -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 diff --git a/docs/plans/2026-10-07-native-ab-transport-t21/t21-pass3.txt b/docs/plans/2026-10-07-native-ab-transport-t21/t21-pass3.txt new file mode 100644 index 00000000..d77aa0f2 --- /dev/null +++ b/docs/plans/2026-10-07-native-ab-transport-t21/t21-pass3.txt @@ -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 diff --git a/docs/plans/2026-10-07-native-ab-transport.md b/docs/plans/2026-10-07-native-ab-transport.md new file mode 100644 index 00000000..acf1d7bd --- /dev/null +++ b/docs/plans/2026-10-07-native-ab-transport.md @@ -0,0 +1,2760 @@ +# Native AB Transport (replace libplctag) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Put a fully managed EtherNet/IP + CIP + PCCC client under the AbCip and AbLegacy drivers, selectable per driver against the existing libplctag path, and prove it with the integration suites, a differential harness, and a read-only real-press gate. + +**Architecture:** Add a new pure-protocol project, `ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport` (encapsulation, CIP paths and services, connection manager, request scheduler with Multiple Service Packet coalescing, PCCC), referenced by both drivers. Each driver gains `Native*` adapters behind its existing runtime/enumerator/template-reader interfaces, plus a `Transport` option (`Libplctag` | `Native`, a string enum) that picks the factory set. libplctag is confined to the `Libplctag*.cs` adapters and translates its `Status` into a driver-owned `AbStatus`. + +**Tech Stack:** .NET 10, C#, xUnit v3 + Shouldly, `System.Net.Sockets`, `System.Buffers.Binary.BinaryPrimitives`, the `ab_server` Docker fixtures on `10.100.0.35`, Blazor (AdminUI form). + +**Spec:** `docs/plans/2026-10-07-native-ab-transport-design.md` (commit `45b6062d`). Executors read both documents. Where this plan and the spec disagree, the deviations listed under Global Constraints win. + +## Global Constraints + +- **Wire format:** everything is little-endian. Use `BinaryPrimitives` and the `CipWriter`/`CipReader` helpers from Task 1. No `BitConverter`, because it is endianness-dependent. +- **The decoders do not change.** `CipSymbolObjectDecoder` and `CipTemplateObjectDecoder` stay byte-for-byte as they are. The native enumerator and template reader must emit the exact buffer layouts those decoders document (spec D8): + - The symbol listing entry is `u32 instanceId, u16 symbolType, u16 elementLength, u32×3 dims, u16 nameLen, name` with **no padding**. + - The template buffer is a 14-byte header `u16 id, u32 defSizeWords, u32 instanceSize, u16 memberCount, u16 handle`, followed by the raw Read Template bytes. +- **`AbStatus` is the only status vocabulary above the adapters.** After Task 2/3 no file outside `Libplctag*.cs` may contain `using libplctag`. Task 11 enforces this with a source-scan guard. +- **`IAbCipTagRuntime.GetStatus()` / `IAbLegacyTagRuntime.GetStatus()` return `(int)AbStatus`** under both transports. A zero value still means success, so every existing `status != 0` check stays correct. +- **The `Transport` option is serialized as a string** (`"Native"` / `"Libplctag"`). That means `JsonStringEnumConverter` in the AdminUI form, and the existing string DTO + `ParseEnum` in the factory extensions. An absent value means `Libplctag` until Task 22 flips it. +- **Press safety, for every native code path:** + - At most **one Forward Open in flight per device**, and a **minimum 1 s** between Forward Open attempts per device. + - Exactly one CIP connection per `(host, port, cipPath, plcAttribute)`. + - Forward Close on the last lease release. + - **Real presses are touched only in Task 21, read-only, one press at a time.** +- **One request in flight per connection.** `EncapsulationClient` serializes requests and `RequestScheduler` has `MaxInFlight = 1` hard-wired. *Deviation from spec §3.1, which allowed a configurable `MaxInFlight`; per YAGNI there is no knob in v1.* +- **One `RequestScheduler` per device connection, never per tag.** It lives in the `DeviceSessionPool` entry beside the `CipConnection` and is exposed as `ConnectionLease.Scheduler`. Per-tag schedulers would make MSP coalescing impossible and race the connected sequence counter. That is review finding B1. +- **The connected sequence count is assigned inside `EncapsulationClient`'s single-flight gate**, never by a caller (B1). +- **Caller cancellation never faults the shared connection.** A poll subscription's CTS is cancelled on every OPC UA unsubscribe (`PollGroupEngine.cs:160`). Faulting the socket on that would drop every tag on the press and force a fresh Forward Open. Instead: + - Frames are written uncancellably (they are small and bounded by the socket). + - Cancellation during the reply wait abandons only the waiter. The stale reply is skipped by context or sequence on the next exchange. + - Only a **timeout**, an I/O error or a malformed frame faults the connection. That is review finding B2. +- **Routing is explicit:** `ConnectionOptions.Routing ∈ { Connected, UnconnectedSend, Direct }`. + - `Connected` = Forward Open + SendUnitData. + - `UnconnectedSend` = SendRRData wrapping 0x52. + - `Direct` = SendRRData carrying the CIP request bare, as libplctag does for SLC/MicroLogix/PLC-5 Execute PCCC even with a `1,0` path. + - The **captures decide** each family's mode (B3). +- **Model tiers:** `mechanical` ⇒ **haiku**, `standard` ⇒ **sonnet**, `frontier` ⇒ **opus**. Each task's metadata carries `model`. +- **Parallel execution:** tasks in the same wave touch disjoint files and may run concurrently, **each in its own git worktree** off `feat/native-ab-transport`. They merge back serially in task-id order. Only Task 1 edits `ZB.MOM.WW.OtOpcUa.slnx`. No task edits `Directory.Packages.props`. +- **Build gate for every task:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` with 0 warnings (`TreatWarningsAsErrors` is on for driver projects), and the task's test command green. +- **Naming:** test classes follow `Tests`, test methods use sentence-style names (`A_refused_forward_open_is_not_retried_inside_the_minimum_interval`), and test projects use xUnit v3 + Shouldly, matching `ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj`. + +**User decisions (already made):** +- "all of the above". All four motivations apply (native-binary pain, correctness/control, performance, policy). +- "1, side-by-side behind a switch". There is a per-driver `Transport` switch and libplctag stays as the fallback. +- "use recommended choices for all choices". The proof standard flips per evidence tier: AbCip reads, browse and templates must pass the real presses read-only; AbCip writes and all of AbLegacy are simulator-proven and recorded as a known gap. libplctag is deleted only after a bench controller exists. The stack lives in a new shared `Driver.Ab.Transport` project, with one connection per device, transparent MSP coalescing, a driver-owned `AbStatus`, reuse of the libplctag-shaped decoder buffers, and differential testing as the primary evidence. +- "generate implementation plan to use subagents with appropriate models in parallel where safe. use fable subagent to review plans when done". + +**Out of plan (blocked, not a task):** spec Phase 6, deleting libplctag. It is blocked until a bench CompactLogix or Micro800 (for writes) and an SLC or MicroLogix (for PCCC) are reachable. Record it in `docs/drivers/AbCip.md` "Known gaps" in Task 20. + +--- + +## Execution waves + +| Wave | Tasks (parallel within a wave) | Notes | +|---|---|---| +| 0 | T1 | Creates the project, the slnx entries, the shared contracts and the empty gate-results doc. | +| 1 | T2, T3, T4, T6 | T2/T3 are driver seams (disjoint projects); T4 captures golden frames from the docker host; T6 is the path encoders. | +| 2 | T5, T7, T8, T11 | T5 needs T4 (capture parity); T7 needs T4+T6; T8 needs T4; T11 needs T2+T3. All disjoint folders. | +| 3 | T10 | The scheduler (needs T7). | +| 4 | T9 | Connection + pool (needs T5, T7, T10): the pool entry owns the scheduler. | +| 5 | T12, T13, T14 | T12/T13 are disjoint AbCip files (need T2, T9); T14 is AbLegacy (needs T3, T8, T9). | +| 6 | T15, T16 | AbCip switch (needs T12, T13) and AbLegacy switch (needs T14), in disjoint projects. | +| 7 | T17 | Integration suites on both transports (needs T15, T16). It appends to the gate-results doc. | +| 8 | T18, T19 | Differential harness (needs T17); AdminUI + `/run` (needs T17, since both append to the gate-results doc, so they are serialized behind T17; T18 and T19 append to **different headed sections** created by T1, which merge cleanly). | +| 9 | T20 | Docs (needs T19's evidence and the T8/T14 verification status). | +| 10 | T21 → T22 | Real-press gate (needs T18), then the default flip (needs T21, T17, T19, T20). Serial. | + +Dependency list (authoritative; mirrors `.tasks.json` `blockedBy`): +T2,T3,T4,T6 ← T1 · T5 ← T1,T4 · T7 ← T4,T6 · T8 ← T4 · T11 ← T2,T3 · T10 ← T7 · T9 ← T5,T7,T10 · +T12,T13 ← T2,T9 · T14 ← T3,T8,T9 · T15 ← T12,T13 · T16 ← T14 · T17 ← T15,T16 · +T18 ← T17 · T19 ← T17 · T20 ← T19 · T21 ← T18 · T22 ← T21,T17,T19,T20 + +**Model per task:** + +| Tier | Model | Tasks | +|---|---|---| +| frontier | opus | T4, T9, T10, T12, T18, T21 | +| standard | sonnet | T1, T2, T3, T5, T6, T7, T8, T13, T14, T15, T16, T17, T19, T20 | +| mechanical | haiku | T11, T22 | + +--- + +### Task 1: Ab.Transport project skeleton + shared contracts + +**Goal:** Create `ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport` and its test project, wired into the solution. It carries the shared vocabulary that every later task codes against: `AbStatus`, `AbTransportException`, `AbStatusCodes`, `CipWriter`/`CipReader`, `CipReply`, and `ICipChannel`. + +**Files:** +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj` +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatus.cs` +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbTransportException.cs` +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs` +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipWriter.cs` +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipReader.cs` +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipReply.cs` +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ICipChannel.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Wire/CipReplyTests.cs` +- Create: `docs/plans/2026-10-07-native-ab-transport-gate-results.md` (skeleton with five empty headed sections, `## ab_server suites (T17)`, `## Differential vs ab_server (T18)`, `## AdminUI /run (T19)`, `## Real press (T21)` and `## Latency (T21)`, so later tasks append to separate sections and merge cleanly) +- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add both projects next to the AbCip entries, lines 41-44 and 112-115) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj` and `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj` (add a `ProjectReference` to Ab.Transport) + +**Acceptance Criteria:** +- [ ] `dotnet build ZB.MOM.WW.OtOpcUa.slnx` succeeds with 0 warnings and both new projects appear in the slnx. +- [ ] `AbStatus.Ok == 0`, and every error member is negative. +- [ ] `AbStatusCodes.FromCip` maps CIP general status as follows. It is aligned with the existing `AbCipStatusMapper.MapCipGeneralStatus`, so that existing mapping does not regress (review finding 13). + - `0x00`/`0x0B` → `Ok` + - `0x04`/`0x05`/`0x16` → `NotFound` + - `0x06` → `Partial` + - `0x08` → `Unsupported` + - `0x0A` → `OutOfBounds` + - `0x0C`/`0x10` → `DeviceStateConflict` + - `0x0E`/`0x0F` → `NotAllowed` + - `0x13` → `TooSmall` + - `0x15` → `TooLarge` + - `0xFF` + `0x2105` → `OutOfBounds` + - `0xFF` + `0x2107` → `TypeMismatch` + - everything else, including `0x1E`, → `BadReply` + `0x1E` (embedded service error) is only meaningful on an MSP envelope, and the scheduler (T10) handles it before calling `FromCip`. +- [ ] `AbStatusCodes.FromPccc` maps STS `0x00→Ok`, `0x10→Unsupported`, `0x20→NotFound`, `0x30→NotAllowed`, `0x40/0x50→DeviceStateConflict`, and `0xF0` maps via EXT STS (`0x06/0x07→NotFound` address problems, else `BadReply`). +- [ ] `CipReply.Parse` round-trips a reply with 0, 1 and 2 extended-status words. +- [ ] The driver projects reference Ab.Transport, and Ab.Transport references no OtOpcUa project. + +**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests` → all pass. + +**Steps:** + +- [ ] **Step 1: Create the projects.** The library csproj mirrors the AbCip csproj property group (net10.0, nullable, `TreatWarningsAsErrors`, `GenerateDocumentationFile`, `NoWarn CS1591`). Its only package reference is `Microsoft.Extensions.Logging.Abstractions`. Check it is already pinned in `Directory.Packages.props` with `grep -n Logging.Abstractions Directory.Packages.props`. If it is absent, do **not** add it; the logger is optional and can be dropped from this project. + +```xml + + + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport + ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport + + + + + + + + +``` + +The test csproj is a copy of `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj` with the RootNamespace and ProjectReference retargeted. Drop the `OTOPCUA0001` NoWarn. + +- [ ] **Step 2: Write the failing tests.** + +```csharp +// tests/.../AbStatusCodesTests.cs +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.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().Where(s => s is not AbStatus.Ok and not AbStatus.Pending)) + ((int)s).ShouldBeLessThan(0); + } +} +``` + +```csharp +// tests/.../Wire/CipReplyTests.cs +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Wire; + +public sealed class CipReplyTests +{ + [Fact] + public void Parses_success_reply_with_data() + { + // 0xCC = reply to Read Tag 0x4C; reserved; status 0; ext size 0; data C4 00 2A 00 00 00 + var r = CipReply.Parse(new byte[] { 0xCC, 0x00, 0x00, 0x00, 0xC4, 0x00, 0x2A, 0x00, 0x00, 0x00 }); + r.Service.ShouldBe((byte)0x4C); + r.GeneralStatus.ShouldBe((byte)0); + r.ExtendedStatus.Length.ShouldBe(0); + r.Data.ToArray().ShouldBe(new byte[] { 0xC4, 0x00, 0x2A, 0x00, 0x00, 0x00 }); + } + + [Fact] + public void Parses_error_reply_with_two_extended_words() + { + var r = CipReply.Parse(new byte[] { 0xCC, 0x00, 0xFF, 0x02, 0x05, 0x21, 0x00, 0x00 }); + r.GeneralStatus.ShouldBe((byte)0xFF); + r.ExtendedStatus.ToArray().ShouldBe(new ushort[] { 0x2105, 0x0000 }); + r.Data.Length.ShouldBe(0); + r.Status.ShouldBe(AbStatus.OutOfBounds); + } + + [Fact] + public void Truncated_reply_throws_BadReply() + { + var ex = Should.Throw(() => CipReply.Parse(new byte[] { 0xCC, 0x00 })); + ex.Status.ShouldBe(AbStatus.BadReply); + } +} +``` + +- [ ] **Step 3: Run the tests and confirm they fail.** Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests`. Expected: build errors (types are not defined yet). + +- [ ] **Step 4: Implement.** + +```csharp +// AbStatus.cs +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +///

Transport-neutral status shared by the libplctag and native AB transports. +/// Zero is success; every error is negative so existing status != 0 checks stay correct. +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, +} +``` + +```csharp +// AbTransportException.cs +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +/// Thrown by both AB transports. drives OPC UA status mapping and the +/// driver's "controller answered, don't throttle the device" classification (05/STAB-8). +public sealed class AbTransportException(AbStatus status, string message, Exception? inner = null) + : Exception(message, inner) +{ + public AbStatus Status { get; } = status; +} +``` + +```csharp +// AbStatusCodes.cs +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +public static class AbStatusCodes +{ + public static AbStatus FromCip(byte general, ReadOnlySpan 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, + }; +} +``` + +```csharp +// Wire/CipWriter.cs +using System.Buffers.Binary; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire; + +/// Growable little-endian writer for CIP / EIP frames. +public sealed class CipWriter +{ + private byte[] _buf = new byte[256]; + public int Length { get; private set; } + + private Span 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 v) { v.CopyTo(Take(v.Length)); return this; } + public CipWriter PadToEven() { if ((Length & 1) == 1) U8(0); return this; } + + /// Overwrite a u16 already written at (length back-patching). + public void PatchU16(int offset, ushort v) => BinaryPrimitives.WriteUInt16LittleEndian(_buf.AsSpan(offset, 2), v); + + public byte[] ToArray() => _buf.AsSpan(0, Length).ToArray(); +} +``` + +```csharp +// Wire/CipReader.cs +using System.Buffers.Binary; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire; + +/// Bounds-checked little-endian reader. Any overrun throws AbTransportException(BadReply). +public ref struct CipReader(ReadOnlySpan data) +{ + private readonly ReadOnlySpan _data = data; + public int Position { get; private set; } + public readonly int Remaining => _data.Length - Position; + + private ReadOnlySpan 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 Bytes(int n) => Take(n); + public ReadOnlySpan Rest() => Take(Remaining); +} +``` + +```csharp +// Cip/CipReply.cs +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; + +/// A parsed CIP message-router reply: service (request code, 0x80 bit stripped), general +/// status, extended status words, and the reply data. +public sealed record CipReply(byte Service, byte GeneralStatus, ushort[] ExtendedStatus, ReadOnlyMemory Data) +{ + public AbStatus Status => AbStatusCodes.FromCip(GeneralStatus, ExtendedStatus); + + public static CipReply Parse(ReadOnlyMemory 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); + } +} +``` + +```csharp +// Cip/ICipChannel.cs +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; + +/// 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. +public interface ICipChannel +{ + /// Largest CIP request (service byte through end of data) one call may carry. + int MaxRequestBytes { get; } + + /// Send one CIP request and return the raw CIP reply bytes (reply-service byte first). + /// Throws on transport failure or timeout. + Task> SendAsync(ReadOnlyMemory cipRequest, CancellationToken cancellationToken); +} +``` + +Add the two `` lines to the slnx. Add `` to both driver csprojs. + +- [ ] **Step 5: Run the tests.** Run `dotnet build ZB.MOM.WW.OtOpcUa.slnx && dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests`. Expected: 0 warnings, all pass. + +- [ ] **Step 6: Commit.** + +```bash +git add docs/plans/2026-10-07-native-ab-transport-gate-results.md src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests ZB.MOM.WW.OtOpcUa.slnx src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj +git commit -m "feat(ab-transport): project skeleton with AbStatus, CIP reply parsing and the ICipChannel seam" +``` + +--- + +### Task 2: AbCip seam — decouple the driver from libplctag types + +**Goal:** Change every AbCip file other than the `Libplctag*.cs` adapters so it speaks `AbStatus` / `AbTransportException` instead of libplctag's `Status` / `LibPlcTagException`. Behaviour stays identical. + +**Files:** +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs`, `LibplctagTagEnumerator.cs`, `LibplctagTemplateReader.cs` (wrap libplctag calls; translate) +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs` +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs` (replace `MapLibplctagStatus(Status)` / `TryGetStatus(LibPlcTagException)` with `MapStatus(AbStatus)`, `MapStatus(int)`, and `MapException`) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs` (`IsReachableStatus(AbStatus)`, `IsReachableException(AbTransportException)`; construct runtimes via an `IAbCipTagFactory` parameter that defaults to `new LibplctagTagFactory()`) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:1,1186-1191` (catch `AbTransportException`) +- Modify (tests): `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs`, `AbCipDriverProbeTests.cs`, `AbCipDriverReadTests.cs`, `AbCipStatusMapperTests.cs`, `AbCipDriverCodeReviewRegressionTests.cs` (line 326 uses `libplctag.Status.ErrorBadConnection`), `AbCipUnknownTagReadTests.cs`, `AbCipLoggingTests.cs` (line 91, same), `FakeAbCipTag.cs`. Run `grep -rln "libplctag" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --include=*.cs` and migrate **every** hit except `LibplctagStatusTranslationTests.cs` and the mapper parity oracle. +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs` (its `` must become `MapStatus(int)`. A dangling cref is CS1574, which `TreatWarningsAsErrors` turns into a build error.) +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs` + +**Acceptance Criteria:** +- [ ] `grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/*.cs` lists only `LibplctagTagRuntime.cs`, `LibplctagTagEnumerator.cs`, `LibplctagTemplateReader.cs` and `LibplctagStatusTranslation.cs`. +- [ ] `LibplctagTagRuntime.GetStatus()` returns `(int)AbStatus`, and every libplctag call that can throw `LibPlcTagException` rethrows `AbTransportException` with the translated status and the original as `InnerException`. +- [ ] The translation table is the existing `MapLibplctagStatus` buckets re-expressed in `AbStatus`: + - `Ok→Ok`, `Pending→Pending`, `ErrorTimeout→Timeout` + - `ErrorNotFound/ErrorNoMatch/ErrorBadDevice→NotFound` + - `ErrorNotAllowed→NotAllowed` + - `ErrorOutOfBounds→OutOfBounds`, `ErrorTooLarge→TooLarge`, `ErrorTooSmall→TooSmall` + - `ErrorUnsupported/ErrorNotImplemented→Unsupported` + - `ErrorPartial→Partial`, `ErrorAbort→Abort` + - `ErrorBadReply→BadReply` + - every other member → `BadConnection` +- [ ] `AbCipStatusMapper.MapStatus(AbStatus)` produces exactly the OPC UA codes the old mapper produced for the corresponding libplctag members. `TypeMismatch` maps to `BadTypeMismatch` and `DeviceStateConflict` to `BadDeviceFailure`. `Partial` and `BadReply` must still map to `BadCommunicationError`, as `ErrorPartial` / `ErrorBadReply` did. +- [ ] The full existing `AbCip.Tests` suite is green, with the only changes being test fakes constructing `AbTransportException(AbStatus.X, ...)` instead of `LibPlcTagException("ErrorX")` and setting `Status = (int)AbStatus.X`. + +**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests` → all pass, then `grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/*.cs` → exactly the four `Libplctag*` files. + +**Steps:** + +- [ ] **Step 1: Write the translation test (failing).** + +```csharp +// tests/.../LibplctagStatusTranslationTests.cs +using libplctag; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +public sealed class LibplctagStatusTranslationTests +{ + [Theory] + [InlineData(Status.Ok, AbStatus.Ok)] + [InlineData(Status.Pending, AbStatus.Pending)] + [InlineData(Status.ErrorTimeout, AbStatus.Timeout)] + [InlineData(Status.ErrorNotFound, AbStatus.NotFound)] + [InlineData(Status.ErrorNoMatch, AbStatus.NotFound)] + [InlineData(Status.ErrorBadDevice, AbStatus.NotFound)] + [InlineData(Status.ErrorNotAllowed, AbStatus.NotAllowed)] + [InlineData(Status.ErrorOutOfBounds, AbStatus.OutOfBounds)] + [InlineData(Status.ErrorTooLarge, AbStatus.TooLarge)] + [InlineData(Status.ErrorTooSmall, AbStatus.TooSmall)] + [InlineData(Status.ErrorUnsupported, AbStatus.Unsupported)] + [InlineData(Status.ErrorNotImplemented, AbStatus.Unsupported)] + [InlineData(Status.ErrorPartial, AbStatus.Partial)] + [InlineData(Status.ErrorAbort, AbStatus.Abort)] + [InlineData(Status.ErrorBadReply, AbStatus.BadReply)] + [InlineData(Status.ErrorBadGateway, AbStatus.BadConnection)] + [InlineData(Status.ErrorWinsock, AbStatus.BadConnection)] + public void Translates(Status from, AbStatus to) => LibplctagStatusTranslation.ToAbStatus(from).ShouldBe(to); + + [Fact] + public void Exception_message_is_recovered_as_the_enum_name() + { + LibplctagStatusTranslation.ToAbStatus(new LibPlcTagException("ErrorNotFound")).ShouldBe(AbStatus.NotFound); + LibplctagStatusTranslation.ToAbStatus(new LibPlcTagException("gibberish")).ShouldBe(AbStatus.BadConnection); + } +} +``` + +Add a matching parity test to `AbCipStatusMapperTests`: for every `Status` member, `MapStatus(ToAbStatus(s))` must equal the OPC UA code the **old** `MapLibplctagStatus(s)` returned. Copy the old switch into the test as an expected-value table **before deleting it from production**. That table is the regression oracle. + +- [ ] **Step 2: Run and confirm it fails.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter LibplctagStatusTranslation` → compile error. + +- [ ] **Step 3: Implement the translation and wrap the adapters.** + +```csharp +// LibplctagStatusTranslation.cs +using libplctag; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// The ONLY place libplctag's meets . +internal static class LibplctagStatusTranslation +{ + 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, + }; + + /// libplctag builds the exception from Status.ToString(), so the message is the enum name. + public static AbStatus ToAbStatus(LibPlcTagException ex) => + Enum.TryParse((ex.Message ?? "").Trim(), ignoreCase: true, out var s) && Enum.IsDefined(s) + ? ToAbStatus(s) + : AbStatus.BadConnection; + + public static async Task Wrap(Func op) + { + try { await op().ConfigureAwait(false); } + catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); } + } +} +``` + +In `LibplctagTagRuntime`: `InitializeAsync/ReadAsync/WriteAsync => LibplctagStatusTranslation.Wrap(() => _tag.XAsync(ct))`, and `GetStatus() => (int)LibplctagStatusTranslation.ToAbStatus(_tag.GetStatus())`. Do the same in the enumerator's `ReadListingAsync` and in `LibplctagTemplateReader.ReadAsync`. + +- [ ] **Step 4: Rewrite the mapper and probe and the driver catch.** + +`AbCipStatusMapper` (remove `using libplctag`): + +```csharp +public static uint MapStatus(int status) => MapStatus((AbStatus)status); + +public static uint MapStatus(AbStatus status) => status switch +{ + 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 +}; + +public static uint MapException(Exception ex) => + ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError; +``` + +Rename every caller of `MapLibplctagStatus` to `MapStatus`; `grep -rn MapLibplctagStatus src tests` finds them all. Keep `MapCipGeneralStatus` unchanged. + +`AbCipDriverProbe`: + +```csharp +internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound; +internal static bool IsReachableException(AbTransportException ex) => IsReachableStatus(ex.Status); +``` + +In the probe body, replace `(Status)rt.GetStatus()` with `(AbStatus)rt.GetStatus()`, `statusCode == Status.Ok` with `== AbStatus.Ok`, and `catch (LibPlcTagException ex)` with `catch (AbTransportException ex)`. Replace `new LibplctagTagRuntime(p)` with `tagFactory.Create(p)`, where `tagFactory` is a new optional parameter (`IAbCipTagFactory? tagFactory = null` ⇒ `new LibplctagTagFactory()`). Task 15 passes the transport-selected factory. + +`AbCipDriver.cs:1186`: + +```csharp +if (ex is AbTransportException ate + && ate.Status != AbStatus.Ok + && AbCipDriverProbe.IsReachableStatus(ate.Status)) + device.Backoff.RecordSuccess(); +``` + +Remove `using libplctag;` from `AbCipDriver.cs` and add `using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;`. Rename log template placeholders `{LibplctagStatus}` → `{Status}`, and change the messages from "libplctag status" to "transport status". + +- [ ] **Step 5: Migrate the tests.** + - In `FakeAbCipTag` and every test that sets `Status =` / `new LibPlcTagException("ErrorX")`: use `(int)AbStatus.X` / `new AbTransportException(AbStatus.X, "X")`. + - Map the old names this way: `ErrorNotFound→NotFound`, `ErrorTimeout→Timeout`, `ErrorBadConnection→BadConnection`, `ErrorNotAllowed→NotAllowed`, `ErrorOutOfBounds→OutOfBounds`. + - In `AbCipDriverProbeTests` line 174/188, change the theory to iterate `AbStatus` values. "something the wrapper has never said" becomes `new AbTransportException(AbStatus.BadConnection, "...")`, and the expectation is false. + +- [ ] **Step 6: Run the suite.** Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests`. Expected: all pass, with the same test count as before plus the new translation tests. + +- [ ] **Step 7: Commit.** + +```bash +git add -A src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests +git commit -m "refactor(abcip): confine libplctag to its adapters behind AbStatus/AbTransportException" +``` + +--- + +### Task 3: AbLegacy seam — decouple the driver from libplctag types + +**Goal:** Do the same as Task 2 for AbLegacy. Only `LibplctagLegacyTagRuntime.cs` and `LibplctagLegacyStatusTranslation.cs` may reference libplctag. + +**Files:** +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs` +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs` +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyStatusMapper.cs` (`MapStatus(AbStatus|int)`, `MapException`; keep `MapPcccStatus`) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs` (`IsReachableStatus(AbStatus)`, catch `AbTransportException`, optional `IAbLegacyTagFactory` parameter defaulting to `new LibplctagLegacyTagFactory()`) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs` (lines ~318-325: `MapLibplctagStatus`→`MapStatus`; log text "transport status"; any `LibPlcTagException` catch → `AbTransportException`) +- Modify (tests): `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyEvictOnFailureTests.cs`, `AbLegacyReadWriteTests.cs` (line 73), `AbLegacyHostAndStatusTests.cs`, `AbLegacyBitRmwTests.cs` (line 232), `AbLegacyArrayTests.cs` (line 115), `FakeAbLegacyTag.cs`. Run `grep -rln "libplctag" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --include=*.cs` and migrate every hit except the translation test and the parity oracle. Also grep `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy` for `cref=".*MapLibplctagStatus` and fix any match (CS1574). +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyStatusTranslationTests.cs` + +**Acceptance Criteria:** +- [ ] `grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/*.cs` lists exactly `LibplctagLegacyTagRuntime.cs` and `LibplctagLegacyStatusTranslation.cs`. +- [ ] The translation table is identical to Task 2's, applied in the AbLegacy namespace. The table is duplicated, not shared, because each driver owns its own libplctag confinement and Task 22's eventual deletion removes both. +- [ ] `AbLegacyStatusMapper.MapStatus(AbStatus)` gives exactly the outputs the old `MapLibplctagStatus` gave for the corresponding members, proven by a parity table copied from the old switch before deleting it. +- [ ] The full `AbLegacy.Tests` suite is green. + +**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests` → all pass. + +**Steps:** + +- [ ] **Step 1: Write the failing tests.** Copy Task 2's `LibplctagStatusTranslationTests` verbatim into `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyStatusTranslationTests.cs`, and change the namespace to `ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests` and the class under test to `LibplctagLegacyStatusTranslation`. Add the mapper parity table to `AbLegacyHostAndStatusTests`, built from the old `AbLegacyStatusMapper.MapLibplctagStatus` switch at `AbLegacyStatusMapper.cs:42-55`. +- [ ] **Step 2: Run and confirm it fails.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter LibplctagLegacyStatusTranslation` → compile error. +- [ ] **Step 3: Implement.** Create `LibplctagLegacyStatusTranslation` with the same code as Task 2's `LibplctagStatusTranslation` (namespace `ZB.MOM.WW.OtOpcUa.Driver.AbLegacy`). Then make these changes: + - Wrap `InitializeAsync/ReadAsync/WriteAsync` in `LibplctagLegacyTagRuntime` with `LibplctagLegacyStatusTranslation.Wrap(...)`. + - `GetStatus() => (int)LibplctagLegacyStatusTranslation.ToAbStatus(_tag.GetStatus())`. + - The mapper becomes: + +```csharp +public static uint MapStatus(int status) => MapStatus((AbStatus)status); +public static uint MapStatus(AbStatus status) => status switch +{ + 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, +}; +public static uint MapException(Exception ex) => + ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError; +``` + + If `AbLegacyStatusMapper` does not yet define `BadTypeMismatch`, add `public const uint BadTypeMismatch = 0x80740000u;` (the same value as `AbCipStatusMapper`). + - In the probe: `IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound`, and every `Status.*` use moves to `AbStatus.*`. +- [ ] **Step 4: Migrate the tests.** `FakeAbLegacyTag.Status` values and any `LibPlcTagException` move to `(int)AbStatus.X` / `AbTransportException`. +- [ ] **Step 5: Run the suite.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests` → all pass. +- [ ] **Step 6: Commit.** + +```bash +git add -A src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests +git commit -m "refactor(ablegacy): confine libplctag to its adapter behind AbStatus/AbTransportException" +``` + +--- + +### Task 4: Capture golden frames (libplctag ⇄ ab_server) + +**Goal:** Capture, once, the exact request/reply bytes that libplctag 1.5.2 exchanges with `ab_server` for every operation the native stack must reproduce. Store them as hex fixtures that Tasks 7 and 8 assert parity against. + +**Files:** +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/README.md` (how each capture was taken: commit, image tag, command) +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/*.hex` (one file per exchange, as below) +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFile.cs` (loader) +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFileTests.cs` +- Create: `scripts/ab-capture/capture.sh` (re-runnable recipe) + +**Acceptance Criteria:** +- [ ] Each `.hex` file holds alternating `> ` (client→server) and `< ` (server→client) lines of **encapsulation frames**. Each line is one full EIP frame (the 24-byte header plus data) as uppercase hex with no spaces. Lines starting with `#` are comments. +- [ ] These captures exist and are non-empty: + - `logix-register-session.hex` + - `logix-forward-open.hex` (both a Large FO 0x5B and, if libplctag falls back or uses it, a 0x54) + - `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` (CIP 0x04, via patch 0002) + - `logix-tags-listing.hex` (`@tags`, via patch 0001) + - `logix-forward-close.hex` + - `micro800-read-dint.hex` (UCMM, no Forward Open) + - `slc500-read-n7.hex`, `slc500-write-n7.hex`, `slc500-read-f8.hex`, `micrologix-read-n7.hex`, `plc5-read-n7.hex` +- [ ] `CaptureFileTests` loads every `.hex` file, parses every frame header, and asserts that each frame's encapsulation length field equals its data length. +- [ ] `Captures/README.md` records, for each file: the libplctag version (1.5.2), the ab_server image (`otopcua-ab-server:libplctag-release`), the compose profile, and the libplctag tag string used. +- [ ] `Captures/README.md` has a **"Routing observed"** table, one row per family (controllogix, micro800, slc500, micrologix, plc5, plus logixpccc if reachable), with the columns: ForwardOpen yes/no · CIP request wrapped in Unconnected Send yes/no · route bytes · requestor-id bytes (PCCC). Tasks 9, 12 and 14 read their `Routing` choice from this table. + +**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter CaptureFile` → pass, and `ls tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/*.hex | wc -l` → ≥ 17. + +**Steps:** + +- [ ] **Step 1: Bring up each fixture on the docker host, one profile at a time** (they all bind :44818): + +```bash +ssh dohertj2@10.100.0.35 'cd /opt/otopcua-abcip && docker compose --profile controllogix up -d' +``` + +If `/opt/otopcua-abcip` is missing or stale, rsync first. Read the fixture's `docs/drivers/AbServer-Test-Fixture.md` for the exact path: + +```bash +rsync -av tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/ dohertj2@10.100.0.35:/opt/otopcua-abcip/ +``` + +For AbLegacy, the stack lives at **`~/otopcua-ablegacy`** on the docker host, not `/opt` (`infra/README.md:41,68`). It is driven by `AB_LEGACY_COMPOSE_PROFILE= docker compose up -d`, and its source is `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/`. **Read that compose file for the published ports**; do not assume `:44818`. For containers that publish other ports, or run concurrently, capture inside the container's network namespace with the netshoot form in Step 2. + +- [ ] **Step 2: Capture server-side** while driving libplctag from the Mac with the existing CLIs (`otopcua-abcip-cli` / `otopcua-ablegacy-cli`; run `dotnet run --project src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli -- --help` for the verbs): + +```bash +ssh dohertj2@10.100.0.35 'sudo timeout 20 tcpdump -i any -U -w /tmp/cap.pcap tcp port 44818' & +dotnet run --project src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli -- read --gateway 10.100.0.35 --path 1,0 --tag TestDINT --type DInt +wait; scp dohertj2@10.100.0.35:/tmp/cap.pcap "$CAPDIR/logix-read-dint.pcap" # CAPDIR = a fresh empty dir under the session scratchpad +``` + +If the CLI flags differ, use the CLI's own `--help` output. Do not invent flags. If `sudo tcpdump` is unavailable on the host, use `docker run --rm --net=container:otopcua-ab-server-controllogix nicolaka/netshoot tcpdump -U -w - tcp port 44818 > cap.pcap` instead. + +- [ ] **Step 3: Convert each pcap into the `.hex` line format.** Use `scripts/ab-capture/capture.sh`, which runs `tshark -r X.pcap -T fields -e tcp.srcport -e tcp.payload -Y 'tcp.len>0'`, re-joins segments into EIP frames using the 24-byte header length field, and emits `>`/`<` by direction (srcport 44818 ⇒ `<`). Install tshark on the Mac with `brew install wireshark` if it is absent. + +- [ ] **Step 4: Write the loader and its test.** + +```csharp +// Captures/CaptureFile.cs +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures; + +internal sealed record CapturedFrame(bool FromClient, byte[] Bytes); + +internal static class CaptureFile +{ + public static string Dir => Path.Combine(AppContext.BaseDirectory, "Captures"); + + public static IReadOnlyList 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(); + + /// The CIP payload of a SendRRData / SendUnitData frame (the last CPF item's data, + /// minus the 2-byte sequence count for connected data). + 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 + 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 cmd is 0x6F or 0x70 ? last : []; + } +} +``` + +Add `` to the test csproj. `CaptureFileTests` iterates `Directory.GetFiles(CaptureFile.Dir, "*.hex")` and asserts, for every frame, `BitConverter.ToUInt16(bytes, 2) == bytes.Length - 24`. + +- [ ] **Step 5: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter CaptureFile` → pass. + +- [ ] **Step 6: Tear down the fixtures** (`docker compose down`) and **commit.** + +```bash +git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/*.csproj scripts/ab-capture +git commit -m "test(ab-transport): golden libplctag<->ab_server frame captures for Logix, Micro800 and PCCC" +``` + +> ⚠️ These captures prove **parity with libplctag**, not correctness. The independent reference is the real-press captures already in `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectRealControllerCaptureTests.cs` and `CipTemplateObjectWireCaptureTests.cs`. Tasks 13 and 21 use those. + +--- + +### Task 5: EtherNet/IP encapsulation codec + `EncapsulationClient` + +**Goal:** A TCP client that frames EIP encapsulation, registers a session, and sends SendRRData (unconnected) and SendUnitData (connected) requests with at most one outstanding request. It also includes a scriptable in-process fake server for tests. + +**Files:** +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationHeader.cs` +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationCodec.cs` +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationClient.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationCodecTests.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationClientTests.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeEipServer.cs` + +**Acceptance Criteria:** +- [ ] `EncapsulationCodec.RegisterSession()` produces exactly `65 00 04 00 00000000 00000000 <8-byte context> 00000000 01 00 00 00`. +- [ ] `EncapsulationCodec.SendRRData(session, ctx, cip, timeoutSec)` produces: header with command `0x006F`; data = interface handle `00000000`, timeout `u16`, item count `02 00`, Null Address item `00 00 00 00`, then the Unconnected Data item `B2 00 ` followed by the cip bytes. +- [ ] `EncapsulationCodec.SendUnitData(session, connId, seq, cip)` produces: command `0x0070`; data = interface handle `00000000`, timeout `00 00`, item count `02 00`, Connected Address item `A1 00 04 00 `, Connected Data item `B1 00 ` followed by the cip bytes. +- [ ] `EncapsulationCodec.ParseReply` returns the header and the CPF item payload. For a connected reply it strips the sequence count. It throws `AbTransportException(BadReply)` when encapsulation status ≠ 0, the length is wrong, or the expected item is missing. +- [ ] `EncapsulationClient`: + - **Lifecycle.** It connects, registers (and stores the session handle from the reply), and `UnRegisterSession`s on `DisposeAsync`. + - **Single flight.** It serializes requests with a `SemaphoreSlim(1,1)`. + - **Connected sequence (B1).** `SendUnitDataAsync(otConnectionId, cip, timeout, ct)` takes **no sequence argument**. The client owns the connected sequence counter and increments it **inside the gate**, wrapping 65535 → 1. + - **Matching replies.** + - Unconnected replies match on the 8-byte sender context. + - Connected (0x70) replies match on the echoed sequence count. + - Any frame that doesn't match (an earlier abandoned request's late reply) is **skipped** and the client keeps reading. It is not treated as an error. + - **Caller cancellation (B2).** + - The frame write uses `CancellationToken.None`, so a write is never half-sent. + - If the *caller's* token is cancelled during the reply wait, the call throws `OperationCanceledException`. The connection is **not** faulted: `IsConnected` stays true, the gate is released, and the late reply is skipped by the next exchange. + - **Faults.** + - The *timeout* elapsing (no reply within `timeout`) throws `AbTransportException(Timeout)` and **faults** the connection by closing the socket. A target that has stopped answering cannot be trusted. + - A socket or I/O error throws `AbTransportException(BadConnection)` and faults the connection. + - `IsConnected` turns false after any fault. +- [ ] Parity: the bytes `RegisterSession` sends, apart from the sender context, equal the first `>` frame of `Captures/logix-register-session.hex`. +- [ ] `FakeEipServer` accepts a script of `Func` steps, records every request it receives, and can be told to stall (to test timeouts) or drop the connection. + +**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~Eip"` → all pass. + +**Steps:** + +- [ ] **Step 1: Write the codec golden tests (failing).** + +```csharp +// Eip/EncapsulationCodecTests.cs +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Eip; + +public sealed class EncapsulationCodecTests +{ + private const ulong Ctx = 0x0807060504030201; + + [Fact] + public void RegisterSession_frame_is_byte_exact() => + Convert.ToHexString(EncapsulationCodec.RegisterSession(Ctx)).ShouldBe( + "6500" + "0400" + "00000000" + "00000000" + "0102030405060708" + "00000000" + "0100" + "0000"); + + [Fact] + public void SendRRData_wraps_cip_in_null_address_and_unconnected_data_items() + { + var frame = EncapsulationCodec.SendRRData(0x11223344, Ctx, new byte[] { 0x4C, 0x02 }, timeoutSeconds: 10); + Convert.ToHexString(frame).ShouldBe( + "6F00" + "1200" + "44332211" + "00000000" + "0102030405060708" + "00000000" + + "00000000" + "0A00" + "0200" + "00000000" + "B2000200" + "4C02"); + } + + [Fact] + public void SendUnitData_wraps_cip_with_connection_id_and_sequence() + { + var frame = EncapsulationCodec.SendUnitData(0x11223344, Ctx, connectionId: 0xAABBCCDD, sequence: 7, new byte[] { 0x4C, 0x02 }); + Convert.ToHexString(frame).ShouldBe( + "7000" + "1800" + "44332211" + "00000000" + "0102030405060708" + "00000000" + + "00000000" + "0000" + "0200" + "A1000400" + "DDCCBBAA" + "B1000400" + "0700" + "4C02"); + } + + [Fact] + public void RegisterSession_matches_libplctag_capture_except_context() + { + var captured = CaptureFile.Load("logix-register-session.hex").First(f => f.FromClient).Bytes; + var ours = EncapsulationCodec.RegisterSession(Ctx); + ours.AsSpan(0, 12).ToArray().ShouldBe(captured.AsSpan(0, 12).ToArray()); // cmd,len,session,status + ours.AsSpan(20).ToArray().ShouldBe(captured.AsSpan(20).ToArray()); // options + data + } + + [Fact] + public void Nonzero_encapsulation_status_is_BadReply() + { + var reply = Convert.FromHexString("6F00" + "0000" + "44332211" + "01000000" + "0102030405060708" + "00000000"); + Should.Throw(() => EncapsulationCodec.ParseReply(reply, expectConnected: false)) + .Status.ShouldBe(AbStatus.BadReply); + } +} +``` + +- [ ] **Step 2: Run and confirm it fails.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter FullyQualifiedName~Eip` → compile error. + +- [ ] **Step 3: Implement the codec.** + +```csharp +// Eip/EncapsulationHeader.cs +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip; + +public static class EncapCommand +{ + public const ushort ListIdentity = 0x0063, RegisterSession = 0x0065, UnRegisterSession = 0x0066, + SendRRData = 0x006F, SendUnitData = 0x0070; +} + +public readonly record struct EncapsulationHeader( + ushort Command, ushort Length, uint SessionHandle, uint Status, ulong SenderContext, uint Options) +{ + public const int Size = 24; +} +``` + +```csharp +// Eip/EncapsulationCodec.cs +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip; + +public static class EncapsulationCodec +{ + 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(); + } + + public static byte[] RegisterSession(ulong ctx) => + Finish(Header(EncapCommand.RegisterSession, 0, ctx).U16(1).U16(0)); + + public static byte[] UnRegisterSession(uint session, ulong ctx) => + Finish(Header(EncapCommand.UnRegisterSession, session, ctx)); + + public static byte[] SendRRData(uint session, ulong ctx, ReadOnlySpan cip, ushort timeoutSeconds) => + Finish(Header(EncapCommand.SendRRData, session, ctx) + .U32(0).U16(timeoutSeconds).U16(2) + .U16(0x0000).U16(0) + .U16(0x00B2).U16((ushort)cip.Length).Bytes(cip)); + + public static byte[] SendUnitData(uint session, ulong ctx, uint connectionId, ushort sequence, ReadOnlySpan cip) => + Finish(Header(EncapCommand.SendUnitData, session, ctx) + .U32(0).U16(0).U16(2) + .U16(0x00A1).U16(4).U32(connectionId) + .U16(0x00B1).U16((ushort)(cip.Length + 2)).U16(sequence).Bytes(cip)); + + public static EncapsulationHeader ReadHeader(ReadOnlySpan b) + { + var r = new CipReader(b); + return new EncapsulationHeader(r.U16(), r.U16(), r.U32(), r.U32(), r.U64(), r.U32()); + } + + /// Parse a reply frame. Returns the header plus the CIP payload of the data item + /// (0x00B2 unconnected / 0x00B1 connected, sequence count stripped). + public static (EncapsulationHeader Header, ReadOnlyMemory Cip, ushort Sequence) ParseReply( + ReadOnlyMemory 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."); + if (h.Command is EncapCommand.RegisterSession or EncapCommand.UnRegisterSession) + return (h, ReadOnlyMemory.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; + for (var i = 0; i < items; i++) + { + var type = r.U16(); + var len = r.U16(); + offset += 4; + if (type == (expectConnected ? 0x00B1 : 0x00B2)) + { + if (!expectConnected) return (h, frame.Slice(offset, len), 0); + var seq = (ushort)(frame.Span[offset] | (frame.Span[offset + 1] << 8)); + return (h, frame.Slice(offset + 2, len - 2), seq); + } + r.Bytes(len); + offset += len; + } + throw new AbTransportException(AbStatus.BadReply, "Reply carried no data item."); + } +} +``` + +- [ ] **Step 4: Write the client tests against `FakeEipServer` (failing).** + +`FakeEipServer` sketch (complete it): + +```csharp +// Fakes/FakeEipServer.cs +using System.Net; +using System.Net.Sockets; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; + +/// Loopback EIP endpoint. Each received frame is passed to Handler; a non-null return is +/// written back. Answers RegisterSession / UnRegisterSession automatically (session 0x11223344). +internal sealed class FakeEipServer : IAsyncDisposable +{ + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _cts = new(); + public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + public List Received { get; } = []; + public int Connections; + public Func Handler { get; set; } = _ => null; + public bool Stall { get; set; } + + public FakeEipServer() { _listener.Start(); _ = AcceptLoop(); } + + private async Task AcceptLoop() + { + while (!_cts.IsCancellationRequested) + { + TcpClient c; + try { c = await _listener.AcceptTcpClientAsync(_cts.Token); } catch { return; } + Interlocked.Increment(ref Connections); + _ = Serve(c); + } + } + + private async Task Serve(TcpClient c) + { + using var _ = c; + var s = c.GetStream(); + var head = new byte[24]; + while (!_cts.IsCancellationRequested) + { + if (!await ReadExact(s, head)) return; + var len = head[2] | (head[3] << 8); + var frame = new byte[24 + len]; + head.CopyTo(frame, 0); + if (!await ReadExact(s, frame.AsMemory(24))) return; + lock (Received) Received.Add(frame); + var cmd = head[0] | (head[1] << 8); + byte[]? reply = cmd switch + { + 0x65 => RegisterReply(frame), + 0x66 => null, + _ => Stall ? null : Handler(frame), + }; + if (reply is not null) await s.WriteAsync(reply, _cts.Token); + } + } + + private static byte[] RegisterReply(byte[] req) + { + var r = (byte[])req.Clone(); + r[4] = 0x44; r[5] = 0x33; r[6] = 0x22; r[7] = 0x11; + return r; + } + + private async Task ReadExact(NetworkStream s, Memory buf) + { + var got = 0; + while (got < buf.Length) + { + int n; + try { n = await s.ReadAsync(buf[got..], _cts.Token); } catch { return false; } + if (n == 0) return false; + got += n; + } + return true; + } + + /// Build a SendRRData reply echoing the request's session + context with the given CIP reply. + public static byte[] RRReply(byte[] request, byte[] cipReply) + { + var session = BitConverter.ToUInt32(request, 4); + var ctx = BitConverter.ToUInt64(request, 12); + return ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip.EncapsulationCodec.SendRRData(session, ctx, cipReply, 0); + } + + public async ValueTask DisposeAsync() { _cts.Cancel(); _listener.Stop(); await Task.CompletedTask; } +} +``` + +Client tests to write: +- `Registers_and_stores_session_handle`: after `ConnectAsync`, `SessionHandle == 0x11223344`. +- `SendRRData_returns_cip_payload`: the handler replies `RRReply(req, [0xCC,0,0,0,0xC4,0,1,0,0,0])`, and the result equals those 10 bytes. +- `Concurrent_sends_are_serialized`: fire 5 `SendRRDataAsync` calls concurrently, with a handler that sleeps 20 ms and records the arrival time. Assert the received frames never overlap; each request arrives only after the previous reply was sent. +- `Stalled_reply_times_out_and_closes_the_socket`: set `Stall = true` and use a 200 ms timeout. Assert `AbTransportException(Timeout)` and `IsConnected == false`. +- `Caller_cancellation_does_not_fault_the_connection` (B2): + - Have the handler delay its reply by 300 ms, and cancel the caller's token at 50 ms. + - Assert `OperationCanceledException` and `IsConnected == true`. + - Then send a second request whose handler answers immediately. Assert it returns **its own** payload: the stale reply arrives first and is skipped. +- `Connected_sequence_is_assigned_inside_the_gate`: fire 20 concurrent `SendUnitDataAsync` calls, with a handler that echoes the sequence. Assert the sequences seen on the wire are strictly increasing in arrival order, with no duplicates. +- `Reply_with_foreign_context_is_skipped`: the handler first writes a reply carrying a different context, then the correct one. The call returns the correct payload. +- `Dispose_sends_UnRegisterSession`: after `DisposeAsync`, `Received` contains a 0x66 frame. + +- [ ] **Step 5: Implement `EncapsulationClient`.** + +```csharp +// Eip/EncapsulationClient.cs +using System.Net.Sockets; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip; + +public sealed class EncapsulationClient : IAsyncDisposable +{ + private readonly TcpClient _tcp; + private readonly NetworkStream _stream; + private readonly SemaphoreSlim _gate = new(1, 1); + private long _ctx; + private ushort _seq; // connected sequence count; only touched under _gate + private volatile bool _faulted; + + public uint SessionHandle { get; private set; } + public bool IsConnected => !_faulted && _tcp.Connected; + + private EncapsulationClient(TcpClient tcp) { _tcp = tcp; _stream = tcp.GetStream(); } + + public static async Task ConnectAsync(string host, int port, TimeSpan timeout, CancellationToken ct) + { + var tcp = new TcpClient { NoDelay = true }; + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(timeout); + try { 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); } + + var c = new EncapsulationClient(tcp); + var reply = await c.ExchangeAsync(ctx => EncapsulationCodec.RegisterSession(ctx), timeout, ct).ConfigureAwait(false); + c.SessionHandle = EncapsulationCodec.ReadHeader(reply.Span).SessionHandle; + return c; + } + + public async Task> SendRRDataAsync(ReadOnlyMemory cip, TimeSpan timeout, CancellationToken ct) + { + var secs = (ushort)Math.Clamp((int)Math.Ceiling(timeout.TotalSeconds), 1, ushort.MaxValue); + var frame = await ExchangeAsync(ctx => EncapsulationCodec.SendRRData(SessionHandle, ctx, cip.Span, secs), timeout, ct).ConfigureAwait(false); + return EncapsulationCodec.ParseReply(frame, expectConnected: false).Cip; + } + + public async Task> SendUnitDataAsync(uint otConnectionId, ReadOnlyMemory cip, + TimeSpan timeout, CancellationToken ct) + { + // The sequence is chosen inside ExchangeAsync's gate (build runs under it), so concurrent + // callers can never put sequences on the wire out of order (review B1). + var frame = await ExchangeAsync(ctx => + { + _seq = _seq == ushort.MaxValue ? (ushort)1 : (ushort)(_seq + 1); + return EncapsulationCodec.SendUnitData(SessionHandle, ctx, otConnectionId, _seq, cip.Span); + }, timeout, ct).ConfigureAwait(false); + return EncapsulationCodec.ParseReply(frame, expectConnected: true).Cip; + } + + /// Write one frame, then read frames until the matching reply arrives. Unconnected + /// replies match on sender context; connected (0x70) replies match on the echoed sequence. + /// Non-matching frames are late replies to abandoned requests and are skipped. + /// Caller cancellation abandons the wait WITHOUT faulting the stream (review B2); only the + /// timeout, an I/O error or a malformed frame faults it. + private async Task> ExchangeAsync(Func build, TimeSpan timeout, CancellationToken ct) + { + if (_faulted) throw new AbTransportException(AbStatus.BadConnection, "Session is faulted; reconnect."); + await _gate.WaitAsync(ct).ConfigureAwait(false); + using var timeoutCts = new CancellationTokenSource(timeout); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token); + try + { + var ctx = (ulong)Interlocked.Increment(ref _ctx); + var request = build(ctx); + var cmd = (ushort)(request[0] | (request[1] << 8)); + var expectSeq = cmd == EncapCommand.SendUnitData ? _seq : (ushort)0; // set by build() above, under the gate + await _stream.WriteAsync(request, CancellationToken.None).ConfigureAwait(false); + while (true) + { + var frame = await ReadFrameAsync(linked.Token).ConfigureAwait(false); + var h = EncapsulationCodec.ReadHeader(frame.Span); + if (h.Command != cmd) continue; + if (cmd == EncapCommand.SendUnitData) + { + if (EncapsulationCodec.ParseReply(frame, expectConnected: true).Sequence != expectSeq) continue; + } + else if (h.SenderContext != ctx) continue; + return frame; + } + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested) + { Fault(); throw new AbTransportException(AbStatus.Timeout, $"No reply within {timeout.TotalMilliseconds:F0} ms."); } + catch (OperationCanceledException) { throw; } // caller cancelled: keep the stream; the late reply is skipped later + catch (AbTransportException) { Fault(); throw; } + catch (Exception ex) when (ex is IOException or SocketException or ObjectDisposedException or EndOfStreamException) + { Fault(); throw new AbTransportException(AbStatus.BadConnection, ex.Message, ex); } + finally { _gate.Release(); } + } + + private async Task> 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 Fault() { _faulted = true; try { _tcp.Close(); } catch { /* already closed */ } } + + public async ValueTask DisposeAsync() + { + if (!_faulted && _tcp.Connected) + { + try + { + var bytes = EncapsulationCodec.UnRegisterSession(SessionHandle, 0); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); + await _stream.WriteAsync(bytes, cts.Token).ConfigureAwait(false); + } + catch { /* best effort */ } + } + _tcp.Dispose(); + _gate.Dispose(); + } +} +``` + +> Note: a *caller* cancellation leaves the stream intact. The abandoned reply is skipped when it arrives. Only a timeout or an I/O fault closes the socket, and `CipConnection` (Task 9) then reconnects through the Forward Open throttle. This distinction is press safety: unsubscribing an OPC UA monitored item must not cost the press a new Forward Open. + +- [ ] **Step 6: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter FullyQualifiedName~Eip` → all pass. +- [ ] **Step 7: Commit.** `git add -A src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes && git commit -m "feat(ab-transport): EtherNet/IP encapsulation codec and single-flight session client"` + +--- + +### Task 6: CIP path encoding + Logix type codes + +**Goal:** Pure encoders for (a) the route from a CIP path string such as `1,0` and (b) the request path for a Logix symbolic tag name. Also provide the Logix atomic type-code table that the native runtime uses for writes and for the type check. + +**Files:** +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs` +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTypeCode.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipPathTests.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTypeCodeTests.cs` + +**Acceptance Criteria:** +- [ ] `CipPath.EncodeRoute("1,0")` → `01 00`. `EncodeRoute("")` → empty. `EncodeRoute("1,0,2,10.0.0.5")` → `01 00 12 08 31 30 2E 30 2E 30 2E 35`. That is port 2 with the extended-link-address flag `0x10 | 2` = `0x12`, length 8, the ASCII text, and no pad because the length is even. A 9-character address gets one pad byte. Port numbers ≥ 15 use the extended-port form `0x0F, u16 port`. +- [ ] `CipPath.EncodeSymbolic("TestDINT")` → `91 08 54 65 73 74 44 49 4E 54`. An odd-length name gets a pad byte after the name. +- [ ] `EncodeSymbolic("Program:Main.Arr[3].Val")` → `91 0C "Program:Main"`, then `91 03 "Arr" 00`, then element `28 03`, then `91 03 "Val" 00`. +- [ ] Element segments use `28 xx` for indices < 256, `29 00 lo hi` for indices < 65536, and `2A 00 b0 b1 b2 b3` above that. A multi-dimensional `[1,2]` emits two element segments. +- [ ] A trailing purely numeric member (`Tag.5`, the bit selector) is **not** encoded. `EncodeSymbolic` throws `ArgumentException`, and `CipPath.SplitBitSelector("Tag.5")` returns `("Tag", 5)`. Bit selection is the runtime's job (Task 12). +- [ ] `CipPath.EncodeClassInstance(cls, inst)` uses 8-bit logical segments (`20 cc 24 ii`) when they fit, otherwise 16-bit (`21 00 cc cc` / `25 00 ii ii`). +- [ ] `CipPath.WithPathSize(segments)` prefixes the size in 16-bit words. +- [ ] `LogixTypeCode` has these constants: `Bool=0xC1, SInt=0xC2, Int=0xC3, DInt=0xC4, LInt=0xC5, USInt=0xC6, UInt=0xC7, UDInt=0xC8, ULInt=0xC9, Real=0xCA, LReal=0xCB, Struct=0x02A0, BoolArray=0xD3`. `ByteWidth(code)` returns 1/1/2/4/8/1/2/4/8/4/8/4. + +**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~CipPath|FullyQualifiedName~LogixTypeCode"` → pass. + +**Steps:** + +- [ ] **Step 1: Write the failing tests** for every acceptance-criteria row above. Each one is an `[InlineData(input, expectedHex)]` theory asserting `Convert.ToHexString(CipPath.EncodeX(input)).ShouldBe(expectedHex)`. For example: + +```csharp +[Theory] +[InlineData("TestDINT", "9108546573744449 4E54")] +[InlineData("Program:Main.Arr[3].Val", "910C50726F6772616D3A4D61696E" + "910341727200" + "2803" + "910356616C00")] +[InlineData("Big[300]", "910342696700" + "29002C01")] +[InlineData("Grid[1,2]", "910447726964" + "2801" + "2802")] +public void Encodes_symbolic(string name, string hex) => + Convert.ToHexString(CipPath.EncodeSymbolic(name)).ShouldBe(hex.Replace(" ", "")); +``` + +- [ ] **Step 2: Run and confirm it fails.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~CipPath|FullyQualifiedName~LogixTypeCode"` → compile error. + +- [ ] **Step 3: Implement.** + +```csharp +// Cip/CipPath.cs +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 +{ + /// Route path from "port,link[,port,link…]". Links that are not a byte value are IP + /// addresses, encoded as extended link-address port segments. + public static byte[] EncodeRoute(string cipPath) + { + var w = new CipWriter(); + if (string.IsNullOrWhiteSpace(cipPath)) return []; + var parts = cipPath.Split(',', StringSplitOptions.TrimEntries); + if (parts.Length % 2 != 0) throw new ArgumentException($"CIP path '{cipPath}' must be port,link pairs."); + for (var i = 0; i < parts.Length; i += 2) + { + var port = int.Parse(parts[i], CultureInfo.InvariantCulture); + var link = parts[i + 1]; + 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(); + } + + 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."); + 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(); + } + + /// Split on '.' outside brackets, keeping "Program:X" whole. + private static IEnumerable 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(); + } + + /// Message Router (class 2, instance 1). + public static readonly byte[] MessageRouter = [0x20, 0x02, 0x24, 0x01]; + + /// Connection Manager (class 6, instance 1). + public static readonly byte[] ConnectionManager = [0x20, 0x06, 0x24, 0x01]; +} +``` + +```csharp +// Logix/LogixTypeCode.cs +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; + + 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, + }; +} +``` + +Fix the `"9108546573744449 4E54"` spacing in the test by stripping spaces (already done with `.Replace(" ", "")`). + +- [ ] **Step 4: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~CipPath|FullyQualifiedName~LogixTypeCode"` → pass. +- [ ] **Step 5: Commit.** `git commit -am`-style, with explicit paths: `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix && git commit -m "feat(ab-transport): CIP route/symbolic path encoders and Logix type codes"` + +--- + +### Task 7: CIP service codecs (Connection Manager, MSP, Logix Read/Write, symbol + template) + +**Goal:** Pure request builders and reply parsers for every CIP service the native AbCip path needs, each proven byte-exact against the Task 4 captures where a capture exists. + +**Files:** +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs` (Unconnected Send 0x52, Forward Open 0x54, Large Forward Open 0x5B, Forward Close 0x4E) +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/MultipleServicePacket.cs` (0x0A) +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixServices.cs` (Read Tag 0x4C, Read Tag Fragmented 0x52, Write Tag 0x4D, Write Tag Fragmented 0x53) +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/SymbolObjectServices.cs` (Get Instance Attribute List 0x55 on class 0x6B) +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/TemplateObjectServices.cs` (Get Attribute List 0x03 + Read 0x4C on class 0x6C) +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ConnectionManagerServicesTests.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/MultipleServicePacketTests.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixServicesTests.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/SymbolObjectServicesTests.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/TemplateObjectServicesTests.cs` + +**Acceptance Criteria:** +- [ ] **Unconnected Send** `0x52` to `ConnectionManager` builds: priority/tick `0x0A`, timeout ticks `0x0E`, embedded request size `u16`, the embedded request, a pad byte if that size is odd, the route path size in words `u8`, reserved `0x00`, then the route. When the route is empty (Micro800), the builder is not used and the embedded request is sent directly. +- [ ] **Forward Open.** `ForwardOpen(ForwardOpenParams p)` emits: + - service `0x54` (or `0x5B` when `p.Large`), path size `02`, `ConnectionManager`, priority/tick `0x0A`, timeout ticks `0x0E`; + - O→T id `00000000`, T→O id `p.TOConnectionId`, connection serial `p.ConnectionSerial`, vendor id `p.VendorId`, originator serial `p.OriginatorSerial`; + - timeout multiplier `p.TimeoutMultiplier`, 3 reserved bytes; + - O→T RPI `u32`, then O→T params: `u16 0x4200 | size` (regular) or `u32 0x42000000 | size` (large); + - T→O RPI, then T→O params (same encoding); + - transport/trigger `0xA3`, connection path size in words, connection path = `EncodeRoute(cipPath)` + `MessageRouter`. +- [ ] `ParseForwardOpenReply` returns `(OTConnectionId, TOConnectionId, OTApiMicros, TOApiMicros)` on success. On a refusal it surfaces the general and extended status. +- [ ] `ShouldFallBackToRegularOpen(CipReply)` is true when the reply is general `0x08` (Service Not Supported — pre-v20 Logix and other targets answer a Large Forward Open this way, and libplctag falls back on it too) **or** general `0x01` with extended `0x0109` (invalid connection size). Task 9 uses it. +- [ ] `ForwardClose(serial, vendor, origSerial, route)` → `4E 02 20 06 24 01 0A 0E 00 `. +- [ ] **MSP.** `MultipleServicePacket.Build(IReadOnlyList requests)` → `0A 02 20 02 24 01`, count `u16`, then offsets `u16[count]` measured from the start of the count field, then the requests concatenated. `Parse(reply, count)` returns one `CipReply` per embedded reply. `BuildSize(sizes)` returns the exact byte size of an MSP for the given request sizes, without allocating (Task 10 uses it for packing). +- [ ] **Read/Write Tag:** + - `ReadTag(path, elements)` → `4C `. + - `ReadTagFragmented(path, elements, offset)` → `52 … `. + - `WriteTag(path, typeCode, structHandle?, elements, data)` → `4D … [ when type == 0x02A0] `. + - `WriteTagFragmented` → `53 … [] `. + - `ParseReadReply(CipReply)` → `(ushort TypeCode, ushort? StructHandle, ReadOnlyMemory Data)`. For `0x02A0` the next u16 is the handle. +- [ ] **Symbol listing.** `SymbolObjectServices.GetInstanceAttributeList(programName?, startInstance)` → + - `[91 len "Program:X" pad]`, then `20 6B 25 00 `, then attribute count `04 00` and attributes `02 00 07 00 08 00 01 00` (type, element length, array dims, name). + - Each reply entry, as the controller sends it, is `u32 instance, u16 type, u16 elemLen, u32×3 dims, u16 nameLen, name` — exactly the entry layout `CipSymbolObjectDecoder` expects. `ParseListingReply` returns `(byte[] Entries, uint LastInstance, bool More)`, where More ⇔ general status `0x06`. + - ⚠️ If `Captures/logix-tags-listing.hex` shows libplctag requesting a different attribute set or order, **match the capture** and update this criterion. The decoder layout is the invariant; the attribute list is the means. +- [ ] **Templates:** + - `TemplateObjectServices.GetAttributes(templateId)` → `03 03 20 6C 25 00 04 00 04 00 05 00 02 00 01 00`. That is attributes 4 (definition size, words), 5 (structure size, bytes), 2 (member count) and 1 (handle). + - `ParseAttributes` → `(uint DefWords, uint StructBytes, ushort Members, ushort Handle)`; each attribute's reply is `id u16, status u16, value`. + - `ReadTemplate(templateId, offset, bytes)` → `4C 03 20 6C 25 00 `, with More ⇔ status `0x06`. + - `TemplateObjectServices.BuildDecoderBuffer(id, attrs, rawTemplate)` → the 14-byte header + raw bytes from Global Constraints. +- [ ] **Capture parity tests** (each extracts the CIP payload via `CaptureFile.CipPayload`): + - The ReadTag request in `logix-read-dint.hex` equals our `ReadTag(EncodeSymbolic("TestDINT"), 1)`. If libplctag wrapped it in an MSP or Unconnected Send, unwrap the same way in the test, and document that wrapping in a test comment. + - `ParseReadReply` on the captured reply yields `TypeCode == 0xC4` and 4 data bytes. + - The captured Forward Open request equals ours when built with the captured values of the serials and connection ids, which are read out of the capture. + - The captured `@tags` reply entries decode through `CipSymbolObjectDecoder` (reference the AbCip project from the test project only if that is needed; otherwise assert the entry byte layout directly). + +**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~Services|FullyQualifiedName~MultipleServicePacket"` → pass. + +**Steps:** + +- [ ] **Step 1: Write the golden-byte tests (failing)** for each builder above, one `[Fact]` per acceptance row. For example: + +```csharp +[Fact] +public void Forward_close_frame() +{ + var bytes = ConnectionManagerServices.ForwardClose(connectionSerial: 0x1234, vendorId: 0x1337, originatorSerial: 0xCAFEBABE, route: [0x01, 0x00]); + Convert.ToHexString(bytes).ShouldBe("4E02200624010A0E" + "3412" + "3713" + "BEBAFECA" + "03" + "00" + "0100" + "20022401"); +} + +[Fact] +public void Msp_offsets_are_measured_from_the_count_field() +{ + var a = new byte[] { 0x4C, 0x02, 0x91, 0x01, 0x41, 0x00, 0x01, 0x00 }; // 8 bytes + var b = new byte[] { 0x4C, 0x02, 0x91, 0x01, 0x42, 0x00, 0x01, 0x00 }; + Convert.ToHexString(MultipleServicePacket.Build([a, b])).ShouldBe( + "0A02200224010200" + "0600" + "0E00" + Convert.ToHexString(a) + Convert.ToHexString(b)); + MultipleServicePacket.BuildSize([8, 8]).ShouldBe(6 + 2 + 4 + 16); +} + +[Fact] +public void Read_reply_with_struct_handle() +{ + var reply = CipReply.Parse(Convert.FromHexString("CC000000" + "A002" + "CE0F" + "05000000" + "48656C6C6F")); + var (type, handle, data) = LogixServices.ParseReadReply(reply); + type.ShouldBe((ushort)0x02A0); handle.ShouldBe((ushort)0x0FCE); data.Length.ShouldBe(9); +} +``` + +- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~Services|FullyQualifiedName~MultipleServicePacket"` → compile error. + +- [ ] **Step 3: Implement.** The core shapes follow; complete every builder named in the acceptance criteria. + +```csharp +// Cip/ConnectionManagerServices.cs +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; + +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); + +public static class ConnectionManagerServices +{ + public const byte PriorityTick = 0x0A, TimeoutTicks = 0x0E; + + public static byte[] UnconnectedSend(ReadOnlySpan embedded, ReadOnlySpan 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) + { + 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()); + } + + public static byte[] ForwardClose(ushort connectionSerial, ushort vendorId, uint originatorSerial, ReadOnlySpan 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(); + } +} +``` + +> The large-open fallback needs the raw statuses, so add: +> `public static bool ShouldFallBackToRegularOpen(CipReply r) => r.GeneralStatus == 0x08 || (r.GeneralStatus == 0x01 && r.ExtendedStatus.Length > 0 && r.ExtendedStatus[0] == 0x0109);` +> Test both branches, plus a negative case (`0x01`/`0x0100` "connection in use" must NOT fall back). + +```csharp +// Logix/LogixServices.cs +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 static class LogixServices +{ + private static CipWriter Start(byte service, ReadOnlySpan path) => + new CipWriter().U8(service).U8((byte)(path.Length / 2)).Bytes(path); + + public static byte[] ReadTag(ReadOnlySpan path, ushort elements) => Start(0x4C, path).U16(elements).ToArray(); + public static byte[] ReadTagFragmented(ReadOnlySpan path, ushort elements, uint offset) => + Start(0x52, path).U16(elements).U32(offset).ToArray(); + + public static byte[] WriteTag(ReadOnlySpan path, ushort type, ushort? handle, ushort elements, ReadOnlySpan 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 path, ushort type, ushort? handle, ushort elements, uint offset, ReadOnlySpan 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 Data) ParseReadReply(CipReply reply) + { + 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..]); + } +} +``` + +Implement `MultipleServicePacket`, `SymbolObjectServices` and `TemplateObjectServices` to the byte layouts in the acceptance criteria, using `CipWriter`/`CipReader` the same way. + +- [ ] **Step 4: Write and run the capture-parity tests.** If a parity test fails because libplctag sends something different from what this plan specifies (for example a different timeout-ticks byte, or different symbol attributes), **libplctag ⇄ ab_server is the parity reference for framing constants**: change the constant to match, and record the before/after in the test comment. Never change a layout the existing real-press decoder tests rely on. +- [ ] **Step 5: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests` → all pass. +- [ ] **Step 6: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix && git commit -m "feat(ab-transport): Connection Manager, MSP, Logix read/write, symbol and template service codecs"` + +--- + +### Task 8: PCCC codec (Execute PCCC, SLC/MicroLogix typed logical, PLC-5 typed) + +**Goal:** Pure builders and parsers that turn a PCCC file address into an Execute-PCCC CIP request, for both SLC/MicroLogix/LogixPccc and PLC-5. Byte parity is proven against the Task 4 captures. + +**Files:** +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccFileType.cs` +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccAddress.cs` +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccServices.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccAddressTests.cs` + +**Acceptance Criteria:** +- [ ] `PcccAddress(string FileLetter, int? FileNumber, int Element, int? SubElement)` is the transport-side address. It does not depend on `AbLegacyAddress`; Task 14 maps between the two. +- [ ] File type codes: `O=0x8B, I=0x8C, S=0x84, B=0x85, T=0x86, C=0x87, R=0x88, N=0x89, F=0x8A, ST=0x8D, A=0x8E, L=0x91`. Implicit file numbers: `O→0, I→1, S→2`. **Verify every code against `slc500-read-n7.hex` / `slc500-read-f8.hex`**; the capture wins if it disagrees. +- [ ] Sub-element indices: `T/C` → `PRE=1, ACC=2` (word offsets within the 3-word element, where word 0 is the control bits), and `R` → `LEN=1, POS=2`. The bit sub-elements (`EN/TT/DN/CU/CD/OV/UN`) read word 0 and are returned as `(sub=0, bit)`. `PcccAddress.ResolveSubElement` returns `(int word, int? bit)`. +- [ ] `PcccServices.ExecutePccc(requestorVendor, requestorSerial, pccc)` → `4B 02 20 67 24 01 07 `. +- [ ] `PcccServices.SlcTypedRead(tns, addr, byteCount)` → PCCC `0F 00 A2 `, where `u8*` is a single byte when < 255, else `FF lo hi`. +- [ ] `PcccServices.SlcTypedWrite(tns, addr, data)` → `0F 00 AA `. +- [ ] **Byte-count limit (review finding 12).** `byteCount` is a single byte on the wire. `SlcTypedRead`/`SlcTypedWrite` (and the PLC-5 builders) throw `AbTransportException(TooLarge)` when the requested or supplied byte count exceeds `PcccServices.MaxDataBytes = 236`; a silent `(byte)` wrap would be a corrupt write. Test 236 (ok) and 237 (throws) for both. Task 14 chunks larger arrays. +- [ ] `PcccServices.Plc5TypedRead(tns, addr, elements)` → `0F 00 68 `. + - The system address is a level-mask byte (bit0 = file level present? **derive from `plc5-read-n7.hex`**) followed by level values (`$N7:0` ⇒ data table section, file 7, element 0), each `u8*` encoded. + - `Plc5TypedWrite` is `0F 00 67 `. + - ⚠️ The PLC-5 framing must be **copied from the capture**. If ab_server's `--plc=PLC/5` path is not exercised by libplctag in the capture, do **not** ship PLC-5 builders at all. Instead expose `PcccServices.Plc5Verified = false`, have the native AbLegacy runtime throw `AbTransportException(Unsupported)` for `plc5`, and record that in the Task 20 docs. Do not use `[Obsolete]`: under `TreatWarningsAsErrors` it is a build error at every call site. +- [ ] `PcccServices.ParseReply(cipReply)` strips the requestor-id echo (`len u8` + `len-1` bytes), reads `cmd u8 (0x4F), sts u8, tns u16`, then `extSts u8` if `sts == 0xF0`, then the data. Non-zero STS → `AbTransportException(AbStatusCodes.FromPccc(sts, ext))`. A TNS that differs from the request → `BadReply`. +- [ ] Capture parity: `SlcTypedRead` for `N7:0` / `F8:0` equals the PCCC payload inside the captured Execute PCCC request, with the TNS normalized; `ParseReply` on the captured reply yields the 2 / 4 data bytes. + +**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~Pccc"` → pass. + +**Steps:** + +- [ ] **Step 1: Read the captures first.** Decode `slc500-read-n7.hex` by hand (write the decode as comments in `PcccServicesTests`) to fix the exact byte for each field. Check: is the request inside an Unconnected Send? Is it connected via Forward Open? What vendor/serial does libplctag put in the requestor id? This decode is the spec for the steps below. +- [ ] **Step 2: Write the failing tests:** golden bytes for `ExecutePccc`, `SlcTypedRead(N7:0, 2)`, `SlcTypedRead(F8:3, 4)`, `SlcTypedWrite(N7:0, [0x2A,0x00])`, an element ≥ 255 (`N7:300` ⇒ `FF 2C 01`), and the `T4:0.ACC` sub-element 2. Parse tests for success, `sts=0x10` ⇒ `Unsupported`, `sts=0xF0 ext=0x06` ⇒ `NotFound`, and a TNS mismatch ⇒ `BadReply`. Add the capture-parity facts. +- [ ] **Step 3: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter FullyQualifiedName~Pccc` → compile error. +- [ ] **Step 4: Implement.** + +```csharp +// Pccc/PcccServices.cs (core) +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; + +public static class PcccServices +{ + public static byte[] ExecutePccc(ushort vendor, uint serial, ReadOnlySpan pccc) => + new CipWriter().U8(0x4B).U8(2).U8(0x20).U8(0x67).U8(0x24).U8(0x01) + .U8(7).U16(vendor).U32(serial).Bytes(pccc).ToArray(); + + private static void Field(CipWriter w, int v) + { + if (v < 0xFF) w.U8((byte)v); else w.U8(0xFF).U16((ushort)v); + } + + public static byte[] SlcTypedRead(ushort tns, PcccAddress a, byte byteCount) + { + var w = new CipWriter().U8(0x0F).U8(0).U16(tns).U8(0xA2).U8(byteCount); + Field(w, a.EffectiveFileNumber); w.U8(a.FileTypeCode); Field(w, a.Element); Field(w, a.ResolveSubElement().Word); + return w.ToArray(); + } + + public static byte[] SlcTypedWrite(ushort tns, PcccAddress a, ReadOnlySpan data) + { + var w = new CipWriter().U8(0x0F).U8(0).U16(tns).U8(0xAA).U8((byte)data.Length); + Field(w, a.EffectiveFileNumber); w.U8(a.FileTypeCode); Field(w, a.Element); Field(w, a.ResolveSubElement().Word); + return w.Bytes(data).ToArray(); + } + + public static ReadOnlyMemory ParseReply(CipReply reply, ushort expectedTns) + { + if (reply.GeneralStatus != 0) + throw new AbTransportException(reply.Status, $"Execute PCCC CIP status 0x{reply.GeneralStatus:X2}."); + var span = reply.Data.Span; + var r = new CipReader(span); + var idLen = r.U8(); r.Bytes(idLen - 1); + r.U8(); // cmd | 0x40 + var sts = r.U8(); + var tns = r.U16(); + 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}" : "")}."); + if (tns != expectedTns) throw new AbTransportException(AbStatus.BadReply, $"PCCC TNS {tns} != {expectedTns}."); + return reply.Data[r.Position..]; + } +} +``` + +`PcccAddress` exposes `FileTypeCode` (from the table), `EffectiveFileNumber` (explicit, or the implicit number for O/I/S), and `ResolveSubElement()`. The PLC-5 builders come from the capture decode in Step 1. + +- [ ] **Step 5: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter FullyQualifiedName~Pccc` → pass. +- [ ] **Step 6: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc && git commit -m "feat(ab-transport): PCCC Execute-PCCC codec for SLC/MicroLogix typed logical and PLC-5 typed access"` + +--- + +### Task 9: CipConnection + DeviceSessionPool (routing modes, keep-alive, press-safety bounds) + +**Goal:** Own one EIP session, one CIP connection (when routed `Connected`) and **one `RequestScheduler`** per device key. Hand out ref-counted leases exposing both, keep idle class-3 connections alive, and rate-limit Forward Opens so that no failure mode can storm a PLC. + +**Files:** +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/DeviceKey.cs` +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionOptions.cs` (includes the `CipRouting` enum) +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs` (implements `ICipChannel`) +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ForwardOpenThrottle.cs` +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/DeviceSessionPool.cs` (includes `ConnectionLease`) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj` (`InternalsVisibleTo` for `ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests`, `ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests`, `ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests`) +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ForwardOpenThrottleTests.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/DeviceSessionPoolTests.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipTarget.cs` (a `FakeEipServer.Handler` that answers Forward Open / Forward Close / Unconnected Send / Direct / connected data, and counts each) + +**Acceptance Criteria:** +- [ ] `DeviceKey(string Host, int Port, string CipPath, string PlcAttribute)` is a record with case-insensitive `Host`/`PlcAttribute` equality. +- [ ] **`ConnectionOptions`:** + - `Routing` (`CipRouting { Connected, UnconnectedSend, Direct }`) and `AllowPacking` (bool). + - `Timeout` and `ConnectionSize` (int). + - `MinForwardOpenInterval` (default 1 s). + - `RpiMicros` (default 2 000 000) and `TimeoutMultiplier` (default 1 ⇒ ×8, so connection expiry = 16 s). + - `KeepAliveInterval` (default = RPI × 8 / 2 = 8 s). + - `VendorId` (`0x1337`) and `OriginatorSerial` (random per process). +- [ ] **Routing semantics (B3):** + - `Connected`: + - On first use: TCP connect, RegisterSession, Forward Open. When `ConnectionSize > 511` that is a Large FO (0x5B); otherwise a regular one. + - If `ShouldFallBackToRegularOpen` (T7) is true for the reply, it retries **once**, in the same throttled attempt, with a regular open at `Math.Min(ConnectionSize, 504)`. + - Requests go via `SendUnitDataAsync`. The client assigns the sequence (T5). + - `MaxRequestBytes = negotiatedSize - 2`. + - `UnconnectedSend`: requests go via SendRRData wrapped in Unconnected Send 0x52 with the route. `MaxRequestBytes = 504 - (12 + route.Length)`. + - `Direct`: requests go via SendRRData carrying the CIP request bare, even when a CIP path is configured; the path is ignored. `MaxRequestBytes = 504`. +- [ ] **Reconnect:** when the client is faulted (timeout or I/O, never caller cancellation — see T5), the next `SendAsync` reconnects through the throttle. The failing call itself throws its original exception. +- [ ] **Keep-alive (review finding 8, spec §9).** In `Connected` mode, a timer sends Get Attribute Single (`0E 03 20 01 24 01 30 01`: Identity, instance 1, attribute 1 = Vendor ID) through the connection whenever no request has been sent for `KeepAliveInterval`. + - A keep-alive failure faults the connection like any other. + - Keep-alives count towards `RequestsSent`. + - Test with fake time: no keep-alive while traffic flows; exactly one after an idle `KeepAliveInterval`. +- [ ] **No null-race (finding 16).** The open path returns the `(client, connection)` pair it established, and `SendAsync` uses that pair rather than re-reading fields after the lock. +- [ ] **Clean disposal.** Disposal never disposes a `SemaphoreSlim` that waiters may still be queued on. It sets a `_disposed` flag checked under the lock, and later callers get `AbTransportException(Abort)`. +- [ ] **`ForwardOpenThrottle`** (per `DeviceKey`; the pool keeps throttles for the process lifetime, so a reconnect after the last release is still limited): + - At most 1 open attempt in flight. + - An attempt within `MinForwardOpenInterval` of the previous **attempt start** waits for the remainder. If the caller's token fires first, it throws `AbTransportException(BadConnection, "Forward Open throttled")`. + - It uses an injected `TimeProvider`. +- [ ] **Storm test:** against a `FakeCipTarget` refusing every Forward Open, 50 concurrent `SendAsync` callers over 3 s of fake time produce **≤ 4** Forward Open frames. +- [ ] **`DeviceSessionPool`:** + - `Acquire(DeviceKey, ConnectionOptions) → ConnectionLease`. `ConnectionLease : IAsyncDisposable` exposes `ICipChannel Channel` and `RequestScheduler Scheduler`. + - **All leases for one key share one `CipConnection` and one `RequestScheduler`** (B1). The scheduler is built as `new RequestScheduler(connection, options.AllowPacking)`. A second `Acquire` with different `ConnectionOptions` for the same key reuses the first entry's options; that is logged once and pinned by a test. + - The last lease's dispose disposes the scheduler, sends Forward Close (`Connected` only) and then UnRegisterSession. + - A later acquire on the same key builds new instances. + - Thread-safe: 100 parallel acquire/dispose tasks pass. +- [ ] `DeviceSessionPool.Shared` is the process-wide instance; tests build their own. +- [ ] **Diagnostics for the gates (Tasks 18 and 21):** `internal static int ForwardOpenCount` (one increment per Forward Open frame sent, including the fallback) and `internal static int RequestsSent` (one per channel send, keep-alives included), both via `Interlocked`. + +**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~CipConnection|FullyQualifiedName~ForwardOpenThrottle|FullyQualifiedName~DeviceSessionPool"` → pass; the storm test passes 20/20 consecutive runs. + +**Steps:** + +- [ ] **Step 1: Write the failing tests.** + - Use `ManualTimeProvider : TimeProvider` in `Fakes/` (about 20 lines: settable `UtcNow`, plus timers fired by `Advance(TimeSpan)`). Check `Directory.Packages.props` for `Microsoft.Extensions.TimeProvider.Testing` first; use `FakeTimeProvider` if it is already pinned, and **never add a package**. + - Write these tests: + - `Second_attempt_inside_interval_waits` + - `Attempt_after_interval_proceeds_immediately` + - `Only_one_attempt_in_flight` + - `Refusing_target_sees_at_most_one_forward_open_per_interval` + - `Large_open_service_not_supported_falls_back_to_regular_open_once` + - `Large_open_bad_size_falls_back_to_regular_open_once` + - `Connection_in_use_does_not_fall_back` + - `Direct_routing_sends_bare_cip_even_with_a_path` + - `UnconnectedSend_routing_wraps_in_0x52` + - `Connected_routing_never_sends_bare` + - `Caller_cancellation_does_not_reconnect` (cancel mid-request, then send again ⇒ `ForwardOpenCount` unchanged) + - `Timeout_reconnects_through_the_throttle` + - `Idle_connection_sends_one_keepalive_per_interval` + - `Traffic_suppresses_keepalive` + - `Leases_for_one_key_share_one_scheduler` + - `Last_lease_dispose_sends_forward_close_then_unregister` + - `Concurrent_acquire_and_release_is_safe` + - `Send_after_dispose_throws_Abort` +- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~CipConnection|FullyQualifiedName~ForwardOpenThrottle|FullyQualifiedName~DeviceSessionPool"` → compile error. +- [ ] **Step 3: Implement the throttle.** + +```csharp +// Cip/ForwardOpenThrottle.cs +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; + +/// 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. +public sealed class ForwardOpenThrottle(TimeSpan minInterval, TimeProvider time) +{ + private readonly SemaphoreSlim _inFlight = new(1, 1); + private DateTimeOffset? _lastStart; + + public async Task RunAsync(Func> open, CancellationToken ct) + { + await _inFlight.WaitAsync(ct).ConfigureAwait(false); + try + { + if (_lastStart is { } last) + { + var wait = last + minInterval - time.GetUtcNow(); + if (wait > TimeSpan.Zero) + { + try { await Task.Delay(wait, time, ct).ConfigureAwait(false); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { throw new AbTransportException(AbStatus.BadConnection, "Forward Open throttled; caller deadline reached."); } + } + } + _lastStart = time.GetUtcNow(); + return await open(ct).ConfigureAwait(false); + } + finally { _inFlight.Release(); } + } +} +``` + +The throttle's semaphore is never disposed: throttles live for the process. + +- [ ] **Step 4: Implement `CipConnection`.** + +```csharp +// Cip/CipConnection.cs +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; + +public sealed class CipConnection : ICipChannel, IAsyncDisposable +{ + internal static int ForwardOpenCount; + internal static int RequestsSent; + + private sealed record Open(EncapsulationClient Client, ForwardOpenReply? Conn); + + private readonly DeviceKey _key; + private readonly ConnectionOptions _o; + private readonly ForwardOpenThrottle _throttle; + private readonly TimeProvider _time; + private readonly byte[] _route; + private readonly SemaphoreSlim _state = new(1, 1); // never disposed (see DisposeAsync) + private readonly ITimer? _keepAlive; + private Open? _open; + private int _negotiatedSize; + private long _lastSendTicks; + private bool _disposed; + private readonly ushort _connSerial = (ushort)Random.Shared.Next(1, ushort.MaxValue); + private readonly uint _toId = (uint)Random.Shared.Next(); + + public CipConnection(DeviceKey key, ConnectionOptions o, ForwardOpenThrottle throttle, TimeProvider time) + { + _key = key; _o = o; _throttle = throttle; _time = time; + _route = CipPath.EncodeRoute(key.CipPath); + if (o.Routing == CipRouting.Connected) + _keepAlive = time.CreateTimer(_ => _ = KeepAliveAsync(), null, o.KeepAliveInterval, o.KeepAliveInterval); + } + + public int MaxRequestBytes => _o.Routing switch + { + CipRouting.Connected => (_negotiatedSize > 0 ? _negotiatedSize : Math.Min(_o.ConnectionSize, 504)) - 2, + CipRouting.UnconnectedSend => 504 - (12 + _route.Length), + _ => 504, + }; + + public async Task> SendAsync(ReadOnlyMemory cip, CancellationToken ct) + { + var open = await EnsureOpenAsync(ct).ConfigureAwait(false); + Interlocked.Increment(ref RequestsSent); + Interlocked.Exchange(ref _lastSendTicks, _time.GetTimestamp()); + return _o.Routing switch + { + CipRouting.Connected => await open.Client.SendUnitDataAsync(open.Conn!.OTConnectionId, cip, _o.Timeout, ct).ConfigureAwait(false), + CipRouting.UnconnectedSend => await open.Client.SendRRDataAsync(ConnectionManagerServices.UnconnectedSend(cip.Span, _route), _o.Timeout, ct).ConfigureAwait(false), + _ => await open.Client.SendRRDataAsync(cip, _o.Timeout, ct).ConfigureAwait(false), + }; + } + + private async Task KeepAliveAsync() + { + if (_disposed || _open is not { Client.IsConnected: true }) return; + if (_time.GetElapsedTime(Interlocked.Read(ref _lastSendTicks)) < _o.KeepAliveInterval) return; + try { await SendAsync(new byte[] { 0x0E, 0x03, 0x20, 0x01, 0x24, 0x01, 0x30, 0x01 }, CancellationToken.None).ConfigureAwait(false); } + catch (AbTransportException) { /* the client is now faulted; the next real send reconnects through the throttle */ } + } + + private async Task EnsureOpenAsync(CancellationToken ct) + { + if (_open is { } o && o.Client.IsConnected) return o; + await _state.WaitAsync(ct).ConfigureAwait(false); + try + { + if (_disposed) throw new AbTransportException(AbStatus.Abort, "Connection disposed."); + if (_open is { } o2 && o2.Client.IsConnected) return o2; + return _open = await _throttle.RunAsync(OpenAsync, ct).ConfigureAwait(false); + } + finally { _state.Release(); } + } + + private async Task OpenAsync(CancellationToken ct) + { + if (_open is not null) { await _open.Client.DisposeAsync().ConfigureAwait(false); _open = null; } + var client = await EncapsulationClient.ConnectAsync(_key.Host, _key.Port, _o.Timeout, ct).ConfigureAwait(false); + if (_o.Routing != CipRouting.Connected) return new Open(client, null); + try + { + var large = _o.ConnectionSize > 511; + _negotiatedSize = _o.ConnectionSize; + var reply = await ForwardOpenAsync(client, large, _negotiatedSize, ct).ConfigureAwait(false); + if (large && ConnectionManagerServices.ShouldFallBackToRegularOpen(reply)) + { + _negotiatedSize = Math.Min(_o.ConnectionSize, 504); + reply = await ForwardOpenAsync(client, false, _negotiatedSize, ct).ConfigureAwait(false); + } + return new Open(client, ConnectionManagerServices.ParseForwardOpenReply(reply)); + } + catch { await client.DisposeAsync().ConfigureAwait(false); throw; } + } + + private async Task ForwardOpenAsync(EncapsulationClient client, bool large, int size, CancellationToken ct) + { + Interlocked.Increment(ref ForwardOpenCount); + var p = new ForwardOpenParams(large, _toId, _connSerial, _o.VendorId, _o.OriginatorSerial, _o.TimeoutMultiplier, _o.RpiMicros, size, _route); + return CipReply.Parse(await client.SendRRDataAsync(ConnectionManagerServices.ForwardOpen(p), _o.Timeout, ct).ConfigureAwait(false)); + } + + public async ValueTask DisposeAsync() + { + await _state.WaitAsync().ConfigureAwait(false); + Open? open; + try { _disposed = true; open = _open; _open = null; } + finally { _state.Release(); } + 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 + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + await open.Client.SendRRDataAsync(ConnectionManagerServices.ForwardClose(_connSerial, _o.VendorId, _o.OriginatorSerial, _route), TimeSpan.FromSeconds(1), cts.Token).ConfigureAwait(false); + } + catch (Exception ex) when (ex is AbTransportException or OperationCanceledException) { /* PLC expires it after RPI × multiplier */ } + } + await open.Client.DisposeAsync().ConfigureAwait(false); + } +} +``` + +- [ ] **Step 5: Implement `DeviceSessionPool` + `ConnectionLease`.** + - Keep a `Dictionary` under a `lock`, where `Entry = (CipConnection Conn, RequestScheduler Scheduler, ConnectionOptions Options, int RefCount)`. + - Keep a separate, never-pruned `Dictionary`. + - `Acquire` creates the entry on first use (`new CipConnection(key, o, throttle, time)`, `new RequestScheduler(conn, o.AllowPacking)`) and increments `RefCount`. + - `ConnectionLease.DisposeAsync` is idempotent (`Interlocked` flag). It decrements the count and, at zero, removes the entry; **outside the lock** it awaits `Scheduler.DisposeAsync()`, then `Conn.DisposeAsync()`. + - The pool constructor takes an optional `TimeProvider` (default `TimeProvider.System`). +- [ ] **Step 6: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests` → all pass, then run `Refusing_target_sees_at_most_one_forward_open_per_interval` 20× to check it is not flaky. +- [ ] **Step 7: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests && git commit -m "feat(ab-transport): per-device connection with routing modes, shared scheduler, keep-alive and Forward Open throttle"` + +--- + +### Task 10: RequestScheduler — MSP coalescing + fragmentation + +**Goal:** A per-channel scheduler that coalesces concurrent CIP requests into Multiple Service Packets within `MaxRequestBytes`, keeps one request in flight, fans each reply back to its own waiter, and runs fragmented read/write loops for payloads that do not fit in one frame. + +**Files:** +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/RequestScheduler.cs` +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTagClient.cs` (read/write helpers over the scheduler, including the fragment loops) +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/RequestSchedulerTests.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTagClientTests.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipChannel.cs` + +**Acceptance Criteria:** +- [ ] `RequestScheduler(ICipChannel channel, bool allowPacking)` exposes `Task SendAsync(byte[] request, CancellationToken ct)`. +- [ ] **Packing:** while one request is in flight, new requests queue. When the channel frees, the scheduler takes queued requests in FIFO order while `MultipleServicePacket.BuildSize(sizes) <= channel.MaxRequestBytes`. More than one taken ⇒ one MSP. Exactly one ⇒ sent bare. `allowPacking == false` ⇒ always bare. +- [ ] **Boundary tests:** with `MaxRequestBytes = M`, two requests whose MSP size is exactly `M` pack into one frame, and two whose MSP size is `M + 1` go as two frames. +- [ ] **A single request larger than `MaxRequestBytes`** is sent bare. Callers must fragment; `LogixTagClient` does. +- [ ] **Reply fan-out:** each waiter gets its own embedded `CipReply`, with its own general status, so one failing service in an MSP fails only its own waiter. If the MSP envelope itself fails (non-zero general status other than `0x1E` embedded-service-error), or the channel throws, every waiter in that batch faults with the same `AbTransportException`. +- [ ] **Cancellation:** + - A waiter cancelled while queued is removed and never sent. + - A waiter cancelled while its batch is in flight gets `OperationCanceledException`, and the batch's other waiters still complete. + - **The channel call always uses the scheduler's own `_stop` token, never a waiter's**, for single sends as well as MSPs (B2). So one caller's cancellation can never abort, or fault, a shared exchange. + - Each waiter's `CancellationTokenRegistration` is stored on its `Pending` record and **disposed when the TCS completes** (review finding 10). A test makes 10 000 sends on one long-lived token and asserts no growth: measure with a counting `CancellationTokenSource` subclass, or assert `Pending` registrations are disposed via a test hook. +- [ ] **Owner:** one scheduler per device connection, created by `DeviceSessionPool` (T9). This task builds only the class and tests it over `FakeCipChannel`. +- [ ] **`LogixTagClient`:** + - `ReadAsync(path, elements)` sends Read Tag. + - If the reply status is `0x06` (Partial), it **keeps** that reply's data and continues with Read Tag Fragmented at `offset = bytesReceivedSoFar`, appending each reply's data until a reply has status `0x00`. That avoids double-counting the first chunk (review finding 15). + - It returns `(TypeCode, StructHandle, byte[] Data)`. The type code and handle come from the first reply; fragment replies repeat the type header, so strip it from each before appending. + - **Reply-size note:** packing bounds only the *request* size, so many large reads packed together can produce a reply that the controller truncates with `0x06` per service. That is correct behaviour (the fragment loop recovers it), not an error. + - `WriteAsync(path, type, handle, elements, data)` sends one Write Tag when it fits, otherwise Write Tag Fragmented chunks of `MaxRequestBytes - 12 - path.Length`, aligned down to the element width. + - Non-zero final status ⇒ `AbTransportException(reply.Status)`. +- [ ] `FakeCipChannel` lets a test set `MaxRequestBytes`, records every request, and answers via a delegate. It is reused by Tasks 12 and 13. + +**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~RequestScheduler|FullyQualifiedName~LogixTagClient"` → pass. + +**Steps:** + +- [ ] **Step 1: Write `FakeCipChannel` and the failing tests.** + +```csharp +// Fakes/FakeCipChannel.cs +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; + +internal sealed class FakeCipChannel(int maxRequestBytes = 504) : ICipChannel +{ + public int MaxRequestBytes { get; set; } = maxRequestBytes; + public List Sent { get; } = []; + public Func> Respond { get; set; } = r => Task.FromResult(new byte[] { (byte)(r[0] | 0x80), 0, 0, 0 }); + public TaskCompletionSource? Gate { get; set; } + + public async Task> SendAsync(ReadOnlyMemory req, CancellationToken ct) + { + lock (Sent) Sent.Add(req.ToArray()); + if (Gate is { } g) await g.Task.WaitAsync(ct); + return await Respond(req.ToArray()); + } +} +``` + +Tests to write (each with explicit byte sizes): +- `Requests_queued_behind_an_inflight_one_are_packed_into_one_msp`: hold `Gate` with the first request in flight, enqueue 3 more, release. Assert `Sent.Count == 2` and `Sent[1][0] == 0x0A`. +- `Msp_exactly_at_limit_packs` / `Msp_one_byte_over_limit_splits`. +- `Packing_disabled_sends_each_bare`. +- `Per_service_error_fails_only_its_waiter`: the MSP reply carries replies with status 0 and status 0x04. Waiter 1 gets Ok, waiter 2 gets a `CipReply` with `GeneralStatus == 0x04`. +- `Channel_failure_faults_every_waiter_in_the_batch`. +- `Queued_waiter_cancelled_before_send_is_never_sent`. +- `Fragmented_read_keeps_the_first_partial_and_continues`: the first (Read Tag) reply has status 0x06 with 100 data bytes, and the next two are Read Tag Fragmented replies (0x06 with 100 bytes, then 0x00 with 40). The result is 240 bytes, and the fragmented requests were sent at offsets **100 and 200**. +- `Waiter_cancellation_never_reaches_the_channel_token`: the fake channel records the token it received; assert it is never a waiter's token. +- `Cancellation_registrations_are_released`. +- `Fragmented_write_chunks_align_to_element_width`. + +- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~RequestScheduler|FullyQualifiedName~LogixTagClient"` → compile error. + +- [ ] **Step 3: Implement the scheduler.** + +```csharp +// Cip/RequestScheduler.cs +using System.Threading.Channels; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; + +public sealed class RequestScheduler : IAsyncDisposable +{ + private sealed class Pending(byte[] request) + { + public byte[] Request { get; } = request; + public TaskCompletionSource Tcs { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public CancellationTokenRegistration Registration { get; set; } + } + + private readonly ICipChannel _channel; + private readonly bool _allowPacking; + private readonly LinkedList _queue = new(); + private readonly object _lock = new(); + private readonly SemaphoreSlim _signal = new(0); + private readonly CancellationTokenSource _stop = new(); + private readonly Task _pump; + + public RequestScheduler(ICipChannel channel, bool allowPacking) + { _channel = channel; _allowPacking = allowPacking; _pump = Task.Run(PumpAsync); } + + public Task SendAsync(byte[] request, CancellationToken ct) + { + var p = new Pending(request); + LinkedListNode node; + lock (_lock) node = _queue.AddLast(p); + if (ct.CanBeCanceled) + { + p.Registration = ct.Register(() => + { + lock (_lock) { if (node.List is not null) _queue.Remove(node); } + p.Tcs.TrySetCanceled(ct); + }); + // Release the registration as soon as the request completes, however it completes (finding 10). + _ = p.Tcs.Task.ContinueWith(static (_, state) => ((Pending)state!).Registration.Dispose(), p, + CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + } + _signal.Release(); + return p.Tcs.Task; + } + + private List TakeBatch() + { + var batch = new List(); + lock (_lock) + { + var sizes = new List(); + while (_queue.First is { } n) + { + if (batch.Count > 0 && !_allowPacking) break; + sizes.Add(n.Value.Request.Length); + if (batch.Count > 0 && MultipleServicePacket.BuildSize(sizes) > _channel.MaxRequestBytes) break; + batch.Add(n.Value); + _queue.RemoveFirst(); + } + } + return batch; + } + + private async Task PumpAsync() + { + while (!_stop.IsCancellationRequested) + { + try { await _signal.WaitAsync(_stop.Token).ConfigureAwait(false); } catch (OperationCanceledException) { return; } + var batch = TakeBatch(); + if (batch.Count == 0) continue; + try + { + if (batch.Count == 1) + { + var raw = await _channel.SendAsync(batch[0].Request, _stop.Token).ConfigureAwait(false); // never a waiter's token (B2) + batch[0].Tcs.TrySetResult(CipReply.Parse(raw)); + } + else + { + var raw = await _channel.SendAsync(MultipleServicePacket.Build(batch.Select(b => b.Request).ToList()), _stop.Token).ConfigureAwait(false); + var envelope = CipReply.Parse(raw); + if (envelope.GeneralStatus is not (0x00 or 0x1E)) + throw new AbTransportException(envelope.Status, $"MSP failed: 0x{envelope.GeneralStatus:X2}."); + var replies = MultipleServicePacket.Parse(envelope, batch.Count); + for (var i = 0; i < batch.Count; i++) batch[i].Tcs.TrySetResult(replies[i]); + } + } + catch (Exception ex) + { + foreach (var b in batch) b.Tcs.TrySetException(ex); + } + // drain remaining signals for items taken in this batch + for (var i = 1; i < batch.Count; i++) _signal.Wait(0); + } + } + + public async ValueTask DisposeAsync() + { + _stop.Cancel(); + try { await _pump.ConfigureAwait(false); } catch { /* stopping */ } + lock (_lock) foreach (var p in _queue) p.Tcs.TrySetCanceled(); + _signal.Dispose(); _stop.Dispose(); + } +} +``` + +> The requirement "requests queued **while one is in flight** are packed" falls out of the single pump: the pump is busy awaiting the channel, so requests accumulate until it loops back. Do not add a delay-based batching window. + +- [ ] **Step 4: Implement `LogixTagClient`** to the acceptance criteria. It holds a `RequestScheduler` and an `ICipChannel` (for `MaxRequestBytes`), and builds requests with `LogixServices`. +- [ ] **Step 5: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests` → all pass, then run the RequestScheduler tests 20× to check they are not flaky. +- [ ] **Step 6: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests && git commit -m "feat(ab-transport): MSP-coalescing request scheduler and fragmented Logix read/write"` + +--- + +### Task 11: libplctag-confinement guard + +**Goal:** A source-scan test that fails if any production `.cs` file outside the libplctag adapters references `libplctag`, so the eventual deletion stays mechanical. + +**Files:** +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/LibplctagConfinementGuardTests.cs` + +**Acceptance Criteria:** +- [ ] The test walks up from `AppContext.BaseDirectory` to the directory containing `ZB.MOM.WW.OtOpcUa.slnx`, then scans every `*.cs` under `src/` (excluding `bin`/`obj`) for **code** references: `using libplctag` or `libplctag.(Tag|Status|PlcType|Protocol|LibPlcTagException|NativeImport)`. Prose such as the CLI help string `"via libplctag. Second of six…"` (`AbCip.Cli/Program.cs:9`, `AbLegacy.Cli/Program.cs:8`) must **not** match, or the guard is red from wave 2 until T15/T16 (review finding 6). +- [ ] Allowed files: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Libplctag*.cs` and `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/Libplctag*.cs` only. Comments and XML docs are ignored by stripping `//` line comments before matching. +- [ ] The failure message lists each offending file and line. +- [ ] A second fact, `Prose_mentions_of_libplctag_do_not_trip_the_guard`, runs the regex against the literal `"…via libplctag. Second of six driver CLIs"` and asserts no match. +- [ ] **Falsifiability check:** temporarily add `using libplctag;` to `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs`, run the test and see it go red, then revert. Record that you did this in the commit message. + +**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter LibplctagConfinementGuard` → pass. + +**Steps:** + +- [ ] **Step 1: Write the test.** + +```csharp +using System.Text.RegularExpressions; +using Shouldly; +using Xunit; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests; + +public sealed class LibplctagConfinementGuardTests +{ + private static readonly Regex Reference = new( + @"\busing\s+libplctag\b|\blibplctag\.(Tag|Status|PlcType|Protocol|LibPlcTagException|NativeImport)\b", + RegexOptions.Compiled); + + [Fact] + public void Only_the_libplctag_adapters_reference_libplctag() + { + var root = new DirectoryInfo(AppContext.BaseDirectory); + while (root is not null && !File.Exists(Path.Combine(root.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) root = root.Parent; + root.ShouldNotBeNull("could not find the repo root"); + + var offenders = new List(); + foreach (var file in Directory.EnumerateFiles(Path.Combine(root.FullName, "src"), "*.cs", SearchOption.AllDirectories)) + { + if (file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}") || + file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")) continue; + var name = Path.GetFileName(file); + var dir = Path.GetFileName(Path.GetDirectoryName(file)!); + var allowed = name.StartsWith("Libplctag", StringComparison.Ordinal) + && dir is "ZB.MOM.WW.OtOpcUa.Driver.AbCip" or "ZB.MOM.WW.OtOpcUa.Driver.AbLegacy"; + if (allowed) continue; + var lines = File.ReadAllLines(file); + for (var i = 0; i < lines.Length; i++) + { + var code = lines[i].Split("//")[0]; + if (Reference.IsMatch(code)) offenders.Add($"{Path.GetRelativePath(root.FullName, file)}:{i + 1}: {lines[i].Trim()}"); + } + } + offenders.ShouldBeEmpty(string.Join(Environment.NewLine, offenders)); + } +} +``` + +- [ ] **Step 2: Run it** (it should pass now that T2 and T3 have merged). Then do the falsifiability check from the acceptance criteria. +- [ ] **Step 3: Commit.** `git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/LibplctagConfinementGuardTests.cs && git commit -m "test(ab-transport): guard libplctag confinement to its adapters (falsified by adding using libplctag to AbCipDriver.cs)"` + +--- + +### Task 12: `NativeAbCipTagRuntime` + factory + +**Goal:** A native `IAbCipTagRuntime` that leases the device connection from `DeviceSessionPool.Shared`, reads and writes through `LogixTagClient`, decodes the managed buffer with the same semantics as `LibplctagTagRuntime`, and adds a CIP type check. + +**Files:** +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs` +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagFactory.cs` +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipDevice.cs` (maps `AbCipTagCreateParams` → `DeviceKey` + `ConnectionOptions`; shared with Task 13) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs` (add trailing optionals `AbCipDataType? AuthoredType = null, bool UseConnectedMessaging = true` to `AbCipTagCreateParams`) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs` (pass `UseConnectedMessaging: profile.SupportsConnectedMessaging`) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs` (`EnsureTagRuntimeAsync` passes `AuthoredType: def.DataType`; `DeviceState`'s create-params builder passes `UseConnectedMessaging: Profile.SupportsConnectedMessaging`) +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs` +- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj` (add a `ProjectReference` to Ab.Transport and ``.; source-linking compiles the fake into this assembly, so it stays `internal`) + +**Acceptance Criteria:** +- [ ] `NativeAbCipDevice.Key(p)` = `new DeviceKey(p.Gateway, p.Port > 0 ? p.Port : 44818, p.CipPath, p.LibplctagPlcAttribute)`. +- [ ] `NativeAbCipDevice.Options(p)`: + - `Timeout = p.Timeout`, `ConnectionSize = p.ConnectionSize`, `AllowPacking = p.AllowPacking`. + - **`Routing`:** if `p.UseConnectedMessaging` is false, use `UnconnectedSend` when `p.CipPath` is non-empty and `Direct` when it is empty. Otherwise use `Connected`. + - `UseConnectedMessaging` is a new trailing optional `bool UseConnectedMessaging = true` on `AbCipTagCreateParams`. It is set at every construction site from `AbCipPlcFamilyProfile.SupportsConnectedMessaging`, so the family profile stays the single authority and string-matching `"micro800"` is not needed (review finding 18). The sites are `AbCipDriver` `DeviceState` (around `AbCipDriver.cs:1690-1700`, where `AllowPacking`/`ConnectionSize` are already merged) and `AbCipDriverProbe`; find any others with `grep -rn "new AbCipTagCreateParams" src`. + - Cross-check the result against the Task 4 "Routing observed" table for controllogix and micro800, and record any mismatch in the test comment. +- [ ] **Initialization and reads:** + - `InitializeAsync` acquires the lease from `DeviceSessionPool.Shared` and builds a per-runtime `LogixTagClient` over **the lease's shared `Scheduler` and `Channel`**. It must never construct its own `RequestScheduler`: that would defeat MSP coalescing across tags (B1). It encodes the path once, splitting off a trailing bit selector with `CipPath.SplitBitSelector`; that is stored as the runtime's implicit bit. + - It performs **one read** to prove the tag exists. That matches libplctag, whose `InitializeAsync` errors for an unknown tag; the driver's 05/STAB-8 logic depends on create throwing `NotFound`. + - `ReadAsync` reads `p.ElementCount` elements, stores the data, `TypeCode` and `StructHandle`, and sets status `Ok`. On `AbTransportException` it stores the exception's status **and rethrows**, matching libplctag's exception-on-failure behaviour. `GetSize()` returns the data length. +- [ ] **Decode** is byte-identical to `LibplctagTagRuntime` for every `AbCipDataType` (Bool/SInt/USInt/Int/UInt/DInt/UDInt/LInt/ULInt/Real/LReal/String/Dt). Specifically: + - Bool with a bit index ⇒ bit `n` of the little-endian buffer. + - Bool without ⇒ `byte[0] != 0`. + - String ⇒ Logix STRING `u32 LEN` + `DATA`, ASCII, `LEN` clamped to the data length − 4. + - `DecodeArray` uses stride = `data.Length / count` when it divides evenly, else the static width (the same `ResolveArrayStride` rule; call `LibplctagTagRuntime.ResolveArrayStride` with `elementSize: null` to share it). + - Bool arrays are bit-packed (bit `i` of the buffer). +- [ ] **Type check:** after a read, if `TypeCode` is atomic and differs from the authored type's Logix code, `GetStatus()` returns `(int)AbStatus.TypeMismatch` and `Decode*` returns null. The mapping is `AbCipDataType` → `LogixTypeCode`, with `Dt → DInt`; `String`/`Structure` are exempt. Exemptions: + - (a) A BOOL read with a bit index against a DINT host is **not** a mismatch. + - (b) **Authored `Bool` with returned `0xD3`** (the Logix packed BOOL-array type) is **not** a mismatch (review finding 11). + - Tests cover both exemptions, plus a positive mismatch. +- [ ] **Encode/write:** + - `EncodeValue` fills a managed write buffer with the same encodings as `LibplctagTagRuntime.EncodeValue`, including the same `NotSupportedException` cases. + - `WriteAsync` writes with type = the last read `TypeCode` if known, else the authored type's code. + - For String, it uses `Struct` + the last `StructHandle`. If none is known it performs a read first; that read happens inside `WriteAsync`, under the driver's existing per-runtime lock. + - The STRING write payload is `u32 len` + DATA padded to the capacity learned from the last read's data length − 4. +- [ ] `Dispose` releases the lease, waiting for at most 2 s (`lease.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(2))`). It must not fire and forget: an immediate re-init would otherwise open a second session to the press while the old Forward Close is still in flight (review finding 22). It never disposes the shared scheduler; the pool does that on the last release. +- [ ] `NativeAbCipTagFactory : IAbCipTagFactory` creates `NativeAbCipTagRuntime`. +- [ ] Test seam: `internal static NativeAbCipTagRuntime ForTest(ICipChannel channel, AbCipTagCreateParams p)` builds a runtime over the given channel with a private `RequestScheduler(channel, p.AllowPacking)` (test-only; no pool lease). Production code always goes through the factory and `DeviceSessionPool.Shared`. +- [ ] **Unit tests** (against `FakeCipChannel` via `ForTest`): + - decode parity for each type, using a buffer captured from `LibplctagTagRuntime` semantics (hard-coded bytes); + - the TypeMismatch case (REAL returned for a DInt-authored tag); + - an unknown tag ⇒ `InitializeAsync` throws `AbTransportException(NotFound)`; + - a STRING write sends `Struct` + the handle learned from the prior read; + - a 1-element array is read as an array (`IsArray`). + +**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter NativeAbCipTagRuntime` → pass. + +**Steps:** + +- [ ] **Step 1: Write the failing tests**, for example: + +```csharp +[Fact] +public async Task Real_returned_for_a_DInt_authored_tag_is_TypeMismatch() +{ + var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("CC000000" + "CA00" + "0000803F")) }; + using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Speed", AbCipDataType.DInt)); + await rt.InitializeAsync(CancellationToken.None); + await rt.ReadAsync(CancellationToken.None); + rt.GetStatus().ShouldBe((int)AbStatus.TypeMismatch); + rt.DecodeValue(AbCipDataType.DInt, null).ShouldBeNull(); +} + +[Fact] +public async Task Unknown_tag_fails_initialize_with_NotFound() +{ + var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("CC000400")) }; + using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Nope", AbCipDataType.DInt)); + (await Should.ThrowAsync(() => rt.InitializeAsync(CancellationToken.None))).Status.ShouldBe(AbStatus.NotFound); +} +``` + +`AbCipTagCreateParams` has no authored-type field. Add an optional `AbCipDataType? AuthoredType = null` parameter at the **end** of the record (it is binary compatible for every existing named-argument caller). Set it at `AbCipDriver.cs` `EnsureTagRuntimeAsync` from `def.DataType`. The libplctag runtime ignores it. + +- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter NativeAbCipTagRuntime` → compile error. +- [ ] **Step 3: Implement** to the acceptance criteria. Use `BinaryPrimitives` for every decode, never `BitConverter`. +- [ ] **Step 4: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests` → all pass, including every pre-existing test. +- [ ] **Step 5: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests && git commit -m "feat(abcip): native tag runtime over the managed CIP transport, with returned-type checking"` + +--- + +### Task 13: Native AbCip symbol enumerator + template reader + +**Goal:** Native `IAbCipTagEnumerator` and `IAbCipTemplateReader` implementations whose output buffers feed the **unchanged** decoders. + +**Files:** +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/SymbolListingEnumerator.cs` (the transport-neutral listing loop moved out of `LibplctagTagEnumerator.cs`) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs` (keeps only `LibplctagListingReader` + `LibplctagTagEnumeratorFactory`) +- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/` files referencing `LibplctagTagEnumerator` (rename to `SymbolListingEnumerator`) +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagEnumerator.cs` +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTemplateReader.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagEnumeratorTests.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTemplateReaderTests.cs` + +**Acceptance Criteria:** +- [ ] **Enumerator split.** The current `LibplctagTagEnumerator` class (its listing loop has no libplctag dependency apart from its default constructor) is renamed **`SymbolListingEnumerator`**, keeping its `Func> readListing` constructor seam. The libplctag default reader moves into `LibplctagTagEnumerator.cs` as `LibplctagListingReader`, so the Task 11 guard still allows it. `LibplctagTagEnumeratorFactory` returns `new SymbolListingEnumerator(LibplctagListingReader.ReadAsync)`. +- [ ] **Native listing reader.** `NativeAbCipTagEnumerator` = `new SymbolListingEnumerator(NativeListingReader.ReadAsync)`, wrapped in a class for the factory. Its reader: + - `"@tags"` ⇒ pages `SymbolObjectServices.GetInstanceAttributeList(null, start)` from instance 0, appending entry bytes. While the reply is `More`, it continues from `LastInstance + 1`. + - `"Program:X.@tags"` ⇒ the same with `programName: "X"`. + - It returns the concatenated bytes. +- [ ] `NativeAbCipTemplateReader.ReadAsync(params, id)`: + - `GetAttributes(id)` gives `defWords`, `structBytes`, `members`, `handle`. + - It then sends `ReadTemplate(id, offset, chunk)` repeatedly, with `chunk = Math.Min(remaining, MaxRequestBytes - 16)`, where the total = `defWords * 4 - 23` (the CIP-documented Read Template length), advancing by each reply's data length until the status is 0. Status `0x06` continues. + - It returns `TemplateObjectServices.BuildDecoderBuffer(...)`. +- [ ] Unit tests feed the **real controller captures** through the native path. The fake channel serves, as reply data, the bytes contained in `CipTemplateObjectWireCaptureTests` (rev 34.11 press). The test asserts that `CipTemplateObjectDecoder.Decode(nativeBuffer)` equals `Decode(capturedLibplctagBuffer)`, member by member. + - The captured libplctag buffer's header **is** the 14-byte header, so the fake replies are built from that header's fields plus the raw bytes after byte 14. + - Do the same for `CipSymbolObjectRealControllerCaptureTests` entries: split them across two pages with a `0x06` status between, and assert the decoded tag list is identical. +- [ ] Both factories (`NativeAbCipTagEnumeratorFactory`, `NativeAbCipTemplateReaderFactory`) exist and lease from `DeviceSessionPool.Shared` via `NativeAbCipDevice`. They send through **the lease's shared `Scheduler`**, never a private one, and dispose the lease (bounded 2 s wait, as in T12) when the enumeration or read completes. + +**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter "FullyQualifiedName~NativeAbCipTagEnumerator|FullyQualifiedName~NativeAbCipTemplateReader|FullyQualifiedName~Discovery"` → pass. + +**Steps:** + +- [ ] **Step 1: Write the failing tests** that reuse the real-capture byte arrays. Reference them through the existing test classes' fields if those are accessible; otherwise copy the hex into a shared `RealControllerCaptures.cs` and point both old and new tests at it. +- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter "FullyQualifiedName~NativeAbCipTagEnumerator|FullyQualifiedName~NativeAbCipTemplateReader"` → compile error. +- [ ] **Step 3: Split the enumerator.** Move the class body into `SymbolListingEnumerator.cs` as `SymbolListingEnumerator`, leaving `LibplctagListingReader.ReadAsync` and `LibplctagTagEnumeratorFactory` (which now returns `new SymbolListingEnumerator(LibplctagListingReader.ReadAsync)`) in `LibplctagTagEnumerator.cs`. Then implement the native readers. +- [ ] **Step 4: Run** the full AbCip.Tests suite → all pass. +- [ ] **Step 5: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests && git commit -m "feat(abcip): native symbol listing and template reads feeding the unchanged decoders"` + +--- + +### Task 14: `NativeAbLegacyTagRuntime` + factory + +**Goal:** A native `IAbLegacyTagRuntime` over PCCC (Task 8) carried in Execute PCCC through a `DeviceSessionPool` lease. + +**Files:** +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs` +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagFactory.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs` +- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj` (reference Ab.Transport + link `FakeCipChannel.cs`, as in Task 12) + +**Acceptance Criteria:** +- [ ] **Connection:** + - `DeviceKey` is built from `AbLegacyTagCreateParams` the same way as Task 12 (default port 44818). + - **`Routing` comes from the Task 4 "Routing observed" table (B3), per family:** + - Expected: `slc500`/`micrologix`/`plc5` ⇒ **`Direct`** (libplctag sends Execute PCCC bare in SendRRData even though the driver forces `EffectiveCipPath = "1,0"`, `AbLegacyDriver.cs:864`); `logixpccc` ⇒ **`Connected`**. + - **If the capture shows otherwise, the capture wins.** Encode the choice in one `static CipRouting RoutingFor(string plcAttribute)` with a comment citing the capture file, and pin it with a test per family. + - `ConnectionSize = 504`, `AllowPacking = false` (PCCC requests are never MSP-packed). + - Send through the lease's shared `Scheduler`, never a private one (B1). +- [ ] **Address handling:** + - `AbLegacyAddress.TryParse(p.TagName)` ⇒ `PcccAddress(FileLetter, FileNumber, WordNumber, sub)`. The sub-element comes from `ResolveSubElement`. A `BitIndex` is kept for decode only, because reads are word reads. + - Byte counts per element: N/A/B/I/O/S = 2, F/L = 4, T/C/R = 6 when no sub-element (the whole element), 2 when a word sub-element is chosen, ST = 84. + - Element count = `p.ElementCount`. +- [ ] **Read and write:** + - `ReadAsync` dispatches by `plc` attribute: `slc500`/`micrologix`/`logixpccc` ⇒ `SlcTypedRead`; `plc5` ⇒ `Plc5TypedRead`, or `AbTransportException(Unsupported)` when `PcccServices.Plc5Verified` is false (Task 8). + - The TNS increments per request. + - **Chunking (review finding 12):** a read or write whose byte count exceeds `PcccServices.MaxDataBytes` (236) is split into consecutive element ranges of at most 236 bytes (118 words, or 59 longs/floats). The element number advances per chunk and the data is concatenated in order. A failure in any chunk fails the whole operation with that chunk's status; a partial write is reported as failed, never as Good. Test a 256-word array read (three chunks: 118 + 118 + 20) and a 130-word write (two chunks). + - On failure it stores the status and rethrows. + - `InitializeAsync` performs one read, for the same `NotFound`-on-create reason as Task 12. +- [ ] **Decode** is identical to `LibplctagLegacyTagRuntime`: + - Bit with index ⇒ bit of the LE word (`/N` up to 15, or up to 31 for L). + - Bit without ⇒ `(word & 1) != 0`. + - Int/AnalogInt ⇒ `short`→`int`; Long ⇒ `int`; Float ⇒ `float`. + - String ⇒ the SLC ST layout: `u16 LEN` + 82 data bytes **with each byte pair swapped**. ⚠️ Verify this byte order against `slc500` ST data in ab_server. If ab_server does not seed ST, test against a hand-built buffer and flag it in Task 20 docs as unverified. + - Timer/Counter/Control element without a sub-element ⇒ `GetInt32(0)` parity (the first 4 bytes, LE). + - `DecodeArray` uses the offsets documented in `LibplctagLegacyTagRuntime.DecodeArray`. +- [ ] **Encode** follows `LibplctagLegacyTagRuntime.EncodeValue` case by case, with the same `NotSupportedException` for bit-with-index. `WriteAsync` ⇒ `SlcTypedWrite` (or PLC-5 / Unsupported). +- [ ] **Unit tests:** + - N7:0 read decode; + - F8 read; + - B3:0/5 decode; + - an N7 array of 5; + - T4:0.ACC sub-element addressing (bytes on the wire); + - STS 0x20 ⇒ `NotFound` on initialize; + - a write of N7:0 = 42 produces the expected PCCC bytes. + +**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter NativeAbLegacyTagRuntime` → pass. + +**Steps:** + +- [ ] **Step 1: Write the failing tests** against `FakeCipChannel`. Build replies with this helper: + +```csharp +private static byte[] PcccReply(ushort tns, byte[] data) => + [0xCB, 0x00, 0x00, 0x00, 0x07, 0x37, 0x13, 0x00, 0x00, 0x00, 0x00, 0x4F, 0x00, (byte)tns, (byte)(tns >> 8), .. data]; +``` + + Note the leading `0xCB` (0x4B | 0x80) and the 7-byte requestor-id echo. Adjust it to whatever echo the Task 4 capture shows. +- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter NativeAbLegacyTagRuntime` → compile error. +- [ ] **Step 3: Implement** to the acceptance criteria. +- [ ] **Step 4: Run** the full AbLegacy.Tests suite → all pass. +- [ ] **Step 5: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests && git commit -m "feat(ablegacy): native PCCC tag runtime over the managed transport"` + +--- + +### Task 15: AbCip `Transport` switch (options, factory, probe, CLI) + +**Goal:** One `Transport` option selects the native or libplctag factory set everywhere AbCip creates a transport object: the driver, Test Connect, and the CLI. + +**Files:** +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTransport.cs` +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs` (add `public AbCipTransport Transport { get; init; } = AbCipTransport.Libplctag;`) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs` (DTO `string? Transport`; `ParseEnum(dto.Transport, …, fallback: AbCipTransport.Libplctag)`) +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTransportFactories.cs` +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:145-155` (null factories ⇒ `AbCipTransportFactories.For(options.Transport)`) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs` (use `AbCipTransportFactories.For(opts.Transport).Tags`) +- Modify: `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/` (`--transport native|libplctag`, default `libplctag`; help text "via libplctag" → "via libplctag or the native transport") +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs` +- Modify: `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/` (add a `--transport` parse test) + +**Acceptance Criteria:** +- [ ] `public enum AbCipTransport { Libplctag, Native }`. +- [ ] `AbCipTransportFactories.For(t)` returns `(IAbCipTagFactory Tags, IAbCipTagEnumeratorFactory Enumerators, IAbCipTemplateReaderFactory Templates)`: the native trio for `Native`, the libplctag trio for `Libplctag`. +- [ ] Explicitly injected factories (tests) still win over the option. +- [ ] JSON configs: `"transport": "Native"` ⇒ Native; `"transport": "native"` ⇒ Native (case-insensitive, as with the existing `ParseEnum`); absent ⇒ Libplctag; `"transport": "Bogus"` ⇒ the same failure the existing `ParseEnum` raises for a bad `PlcFamily` (do not invent a new behaviour). +- [ ] **Reinit does NOT swap transports, by design (review finding 19).** `AbCipDriver.ReinitializeAsync` (`AbCipDriver.cs:401`) is `ShutdownAsync` + `InitializeAsync` over constructor-held factories, so a transport change in the reinit JSON is inert. Do **not** invent factory swapping: + - Document in the option's XML doc and in `docs/drivers/AbCip.md` (T20) that changing `transport` takes effect when the driver instance is recreated, which a deploy that changes the driver config does. + - Pin the current behaviour with a test, `Reinit_with_a_changed_transport_keeps_the_constructed_transport`, so a future change is deliberate. + - Before writing that test, confirm how a deploy applies a changed `DriverConfig`: `grep -rn "ReinitializeAsync\|CreateInstance" src/Server src/Core | grep -i driver`. If deploys recreate the instance, say so in the doc. If they call `ReinitializeAsync`, raise it as a follow-up in the commit message rather than widening this task. +- [ ] The **probe** deserializes with its own `JsonStringEnumConverter`, so a bad `"transport"` reports "Config JSON is invalid", not the `ParseEnum` message. That is acceptable; assert that exact behaviour in the probe test rather than the driver's. +- [ ] The probe with `Transport: Native` constructs a `NativeAbCipTagRuntime`. Prove it with a test that uses an unreachable `127.0.0.1:1` endpoint and asserts the failure message comes from the native path ("TCP connect … failed"), not libplctag's. +- [ ] The CLI `--transport native` reaches `AbCipDriverOptions.Transport`. + +**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter AbCipTransportSwitch && dotnet test tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests` → pass. + +**Steps:** + +- [ ] **Step 1: Write the failing tests** (option parsing, factory selection, injected-wins, probe path). +- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter AbCipTransportSwitch` → compile error. +- [ ] **Step 3: Implement.** + +```csharp +// AbCipTransportFactories.cs +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +internal static class AbCipTransportFactories +{ + 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()), + }; +} +``` + + In the `AbCipDriver` constructor: + +```csharp +var defaults = AbCipTransportFactories.For(options.Transport); +_tagFactory = tagFactory ?? defaults.Tags; +_enumeratorFactory = enumeratorFactory ?? defaults.Enumerators; +_templateReaderFactory = templateReaderFactory ?? defaults.Templates; +``` + +- [ ] **Step 4: Run** the AbCip and CLI test suites → all pass. +- [ ] **Step 5: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests && git commit -m "feat(abcip): Transport option selects native or libplctag across driver, probe and CLI"` + +--- + +### Task 16: AbLegacy `Transport` switch (options, factory, probe, CLI) + +**Goal:** Do the same as Task 15 for AbLegacy. + +**Files:** +- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTransport.cs` (`public enum AbLegacyTransport { Libplctag, Native }`) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs` (`Transport` property, default `Libplctag`) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs` (DTO + parse) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs:65-71` (`_tagFactory = tagFactory ?? (options.Transport == AbLegacyTransport.Native ? new NativeAbLegacyTagFactory() : new LibplctagLegacyTagFactory());`) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs` +- Modify: `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/` (`--transport`) +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs` +- Modify: `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/` + +**Acceptance Criteria:** the same five criteria as Task 15, applied to AbLegacy types: +- [ ] `"transport": "Native"` and `"native"` select the native factory, absent selects libplctag, and a bad value fails the way an existing bad enum field does. +- [ ] An injected factory wins over the option. +- [ ] The probe with Native uses the native path; prove it with the unreachable-endpoint message test. +- [ ] The CLI `--transport native` reaches the options. +- [ ] A reinit with a changed transport keeps the constructed transport (`AbLegacyDriver` reinit has the same Shutdown+Initialize shape), pinned by the same kind of test and documentation as Task 15. + +**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter AbLegacyTransportSwitch && dotnet test tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests` → pass. + +**Steps:** + +- [ ] **Step 1: Write the failing tests:** option parsing (`"Native"`, `"native"`, absent, `"Bogus"`), injected-factory-wins, the probe's native-path message for `127.0.0.1:1`, and the CLI `--transport native` parse. +- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter AbLegacyTransportSwitch` → compile error. +- [ ] **Step 3: Implement.** + - Add the enum, the options property and the DTO field. Parse it with the file's existing enum-parse helper, falling back to `AbLegacyTransport.Libplctag`. + - Use the constructor line given under **Files**. + - In the probe, build the runtime with `options.Transport == AbLegacyTransport.Native ? new NativeAbLegacyTagFactory() : new LibplctagLegacyTagFactory()` (or the injected factory from Task 3). + - Add `--transport native|libplctag` to the CLI, defaulting to `libplctag`, and change its "via libplctag" help text to "via libplctag or the native transport". +- [ ] **Step 4: Run** the AbLegacy and CLI test suites → all pass. +- [ ] **Step 5: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests && git commit -m "feat(ablegacy): Transport option selects native or libplctag across driver, probe and CLI"` + +--- + +### Task 17: AbCip + AbLegacy integration suites on both transports + +**Goal:** Every existing `ab_server` integration test runs once per transport, and the native run is green wherever the libplctag run is green, against every compose profile. + +**Files:** +- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs` and every other test in that project that constructs `AbCipDriverOptions` (`grep -rln "new AbCipDriverOptions" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests`) +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs` +- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs` and siblings +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs` +- Modify: `docs/plans/2026-10-07-native-ab-transport-gate-results.md` (created empty by Task 1; this task fills only its `## ab_server suites (T17)` section) + +**Acceptance Criteria:** +- [ ] `TransportMatrix.Profiles` yields `(profile, transport)` for every profile × both transports. The theories switch from `MemberData(nameof(Profiles))` to `MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))`, and options get `Transport = transport`. +- [ ] **The AbLegacy matrix is its own class** in the AbLegacy integration project, using `AbLegacyTransport`, and it **preserves the existing `AB_LEGACY_COMPOSE_PROFILE` filter** that `AbLegacyReadSmokeTests.Profiles` applies today (`AbLegacyReadSmokeTests.cs:26-35`): it must filter `KnownProfiles.All` by that env var before crossing with transports. The AbCip snippet below does not apply to AbLegacy verbatim (review finding 20). +- [ ] Run against `10.100.0.35`, one compose profile at a time: + - AbCip: `controllogix`, `compactlogix`, `micro800`, `guardlogix`. + - AbLegacy: `slc500`, `micrologix`, `plc5`. + - **For each profile, the set of passing tests under `Native` ⊇ the set under `Libplctag`.** +- [ ] Any native-only failure is fixed in the transport task's code (reopen that task's files) and is not skipped. The only allowed exception is the PLC-5 `Unsupported` path from Task 8, marked `Skip = "PLC-5 framing unverified — see Task 8"` **only if** Task 8 set `Plc5Verified = false`. +- [ ] The results doc records, under its `## ab_server suites (T17)` heading, per profile × transport, the pass/fail/skip counts plus the `ab_server` image tag and the commit. + +**Verify:** for each profile P (AbCip stack directory as established in Task 4's `Captures/README.md`; AbLegacy at `~/otopcua-ablegacy` with `AB_LEGACY_COMPOSE_PROFILE=P`): `ssh dohertj2@10.100.0.35 'cd && docker compose --profile P up -d'` then `AB_SERVER_ENDPOINT=10.100.0.35:44818 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --logger "trx;LogFileName=P.trx"` → the Native pass set ⊇ the Libplctag pass set (compare the two trx files with `grep -o 'testName="[^"]*" .*outcome="[^"]*"'`). + +**Steps:** + +- [ ] **Step 1: Add `TransportMatrix` and retarget every theory.** + +```csharp +public static class TransportMatrix +{ + public static IEnumerable Profiles => + from p in KnownProfiles.All + from t in new[] { AbCipTransport.Libplctag, AbCipTransport.Native } + select new object[] { p, t }; +} +``` + +- [ ] **Step 2: Run the suites per profile** as in Verify. AbLegacy uses its own Docker dir and its fixture's endpoint env var (read `AbLegacyServerFixture.cs` for the name). +- [ ] **Step 3: Triage every Native-only failure.** Reproduce it with the CLI (`--transport native` vs `--transport libplctag`), compare against the Task 4 capture, fix the code, add a unit test that pins the fix in the owning task's test file, and re-run. +- [ ] **Step 4: Write the results table** and tear down the fixtures. +- [ ] **Step 5: Commit.** `git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests docs/plans/2026-10-07-native-ab-transport-gate-results.md && git commit -m "test(ab): run the ab_server integration suites on both transports"` + +--- + +### Task 18: Differential harness (libplctag vs native, same target) + +**Goal:** A `LiveIntegration` test that reads the same tags through both transports against one endpoint and asserts equal raw buffers and equal decoded values. It skips cleanly when the endpoint env var is absent. + +**Files:** +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs` +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialTarget.cs` +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs` and `NativeAbCipTagRuntime.cs` (add `internal ReadOnlyMemory RawBuffer`) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj` (`InternalsVisibleTo` for `ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests`) + +**Acceptance Criteria:** +- [ ] **Env gating:** + - `AB_DIFF_ENDPOINT` (`host:port`), `AB_DIFF_PATH` (default `1,0`), `AB_DIFF_FAMILY` (default `CompactLogix`). + - `AB_DIFF_TAGS`: an optional comma list. Absent ⇒ every atomic scalar, the first 20 arrays and the first 20 UDT members from browse. + - `AB_DIFF_MAX_TAGS` (default 200). + - Absent `AB_DIFF_ENDPOINT` ⇒ every test reports **skipped**, with the reason naming the variable. +- [ ] **The suite is read-only by construction.** It never calls `WriteAsync` or `EncodeValue`. A guard test reflects over `TransportDifferentialTests` and fails if any method body references `WriteAsync` (scan its source file). +- [ ] **Browse parity:** `@tags` plus every program listing through both enumerators decode to identical `AbCipDiscoveredTag` sets (order-insensitive). Every `TemplateInstanceId` seen decodes to an identical `AbCipUdtShape` through both template readers. +- [ ] **Read parity:** for each selected tag, under both transports: + - Run `InitializeAsync` + `ReadAsync`. + - Assert `GetStatus()` is equal, **except** that native `TypeMismatch` where libplctag says `Ok` is reported as a distinct *expected divergence* (spec §5), listed in the output and not failed. + - Assert the raw buffers are equal. To expose the raw bytes, add `internal ReadOnlyMemory RawBuffer` to both runtimes: libplctag's via `_tag.GetBuffer()`, native's via its stored data. Add `InternalsVisibleTo` for the integration test assembly in the AbCip csproj. + - Assert the decoded values are equal. +- [ ] **Connection discipline (review finding 9):** + - Before any native work, the harness acquires one `DeviceSessionPool.Shared.Acquire(key, options)` **anchor lease** for the target, and holds it until the run ends. Per-tag native runtimes therefore share that one connection: the pool never reaches refcount 0, so it never sends Forward Close and re-opens between tags. + - libplctag runtimes and native runtimes are never alive at the same time. All libplctag reads run first, and their handles are disposed. Then all native reads run. So the press sees at most two sessions over the run, never concurrently. + - With `AB_DIFF_COUNT_OPENS=1`, the native side asserts it made exactly **one** Forward Open over the whole run, using `CipConnection.ForwardOpenCount` from Task 9 (reset it at the start of the run). +- [ ] Output: a markdown table (tag, type, libplctag status, native status, equal?) written to `TestContext.Current.TestOutputHelper`. +- [ ] Results are appended under the gate-results doc's `## Differential vs ab_server (T18)` heading only. +- [ ] Run against `ab_server` (`controllogix` profile) with `AB_DIFF_ENDPOINT=10.100.0.35:44818` ⇒ zero unexpected divergences. Append the results to the gate-results doc. +- [ ] **Ordering:** blocked by Task 17, so any transport fixes found by the ab_server suites land before the differential run. + +**Verify:** `AB_DIFF_ENDPOINT=10.100.0.35:44818 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --filter "FullyQualifiedName~Differential"` → pass with 0 unexpected divergences; without the env var → all skipped. + +**Steps:** + +- [ ] **Step 1: Write the harness.** Use the xUnit v3 `Assert.Skip(...)` call (the project is on xunit.v3), or a dynamic-skip attribute pattern matching `AbServerTheoryAttribute`. +- [ ] **Step 2: Run** against ab_server, fix any divergence (in the owning task's code, with a pinning unit test), and re-run until clean. +- [ ] **Step 3: Commit.** `git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip docs/plans/2026-10-07-native-ab-transport-gate-results.md && git commit -m "test(abcip): read-only differential harness comparing native and libplctag on one target"` + +--- + +### Task 19: AdminUI `Transport` dropdown (AbCip + AbLegacy) + docker-dev `/run` + +**Goal:** An operator can pick the transport per driver in the `/raw` driver-config modal, the value round-trips as a string, and a live docker-dev run proves it reaches the driver. + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor` (`FormModel.Transport`, `ToOptions`/`FromOptions`, an `InputSelect` in the advanced section) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor` (same) +- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs` (add Transport round-trip cases) +- Create or modify: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs` (exists ⇒ add cases; absent ⇒ create, modelled on the AbLegacy file) + +**Acceptance Criteria:** +- [ ] The dropdown has options "libplctag (default)" ⇒ `Libplctag` and "Native (managed)" ⇒ `Native`, bound with `@bind-Value:after="EmitAsync"` the way the other fields are. +- [ ] The serialized JSON contains `"transport":"Native"` (a string, not a number). It goes through the existing `_jsonOpts` with `JsonStringEnumConverter` (`AbCipDriverForm.razor:108-113`); verify the AbLegacy form has the same converter and add it if missing. +- [ ] Loading a config without `transport` shows "libplctag (default)" and re-emits **without** a `transport` key, or with `"Libplctag"`; either is fine, but pick one and test it. +- [ ] **Live `/run` on docker-dev** (login is disabled; drive it yourself with the Chrome tools against `http://localhost:9200`): + 1. Bring up the AbCip `controllogix` fixture on `10.100.0.35`. + 2. In `/raw`, create or edit an AbCip driver with a device `ab://10.100.0.35:44818/1,0`, set Transport = Native, and add the raw tag `TestDINT`. + 3. Deploy. + 4. Read the UNS/raw node with the Client CLI (`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=;s="`) and get `Good`. + 5. Confirm from the central node's logs that the native runtime served it. Add one `LogDebug("AbCip transport={Transport}")` at driver init if no log line distinguishes the two. + 6. Flip back to libplctag, redeploy, and read `Good` again. + 7. Record the output under the gate-results doc's `## AdminUI /run (T19)` heading only. +- [ ] **Inverted-gate check** (memory: "an unchanged observable proves nothing"): with Transport = Native, point the device at a port where no `ab_server` is listening. The read must go **Bad** and the log must show the *native* error text. That proves the switch changes behaviour and isn't inert. + +**Verify:** AdminUI unit tests pass, plus the docker-dev `/run` evidence (CLI output for both transports and the inverted case) is pasted into `docs/plans/2026-10-07-native-ab-transport-gate-results.md`. + +**Steps:** + +- [ ] **Step 1: Write the failing round-trip test.** In the two `*DriverPageFormSerializationTests.cs` files above, assert `ToOptions(FromOptions(x)).Transport == x.Transport` for both forms and both values, and that the serialized JSON carries `"transport":"Native"` as a string. Run it and confirm a compile error. +- [ ] **Step 2: Implement both forms.** Add the `Transport` field to each `FormModel` and to `ToOptions`/`FromOptions`, plus the `InputSelect` in the advanced section, and make sure the AbLegacy form's JSON options include `JsonStringEnumConverter`. Run the round-trip test → pass. +- [ ] **Step 3: Rebuild the docker-dev images** with `docker-dev/deploy.sh`, as `docker-dev/README.md` documents; do not guess flags. +- [ ] **Step 4: Run the live `/run` sequence** above and paste the evidence. +- [ ] **Step 5: Commit.** `git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI tests/Server docs/plans/2026-10-07-native-ab-transport-gate-results.md && git commit -m "feat(adminui): Transport dropdown on AbCip/AbLegacy driver forms (live-verified on docker-dev)"` + +--- + +### Task 20: Documentation + +**Goal:** Document the native transport, the switch, the known gaps and the client-visible type-check change, in the repo and in the umbrella history. + +**Files:** +- Modify: `docs/drivers/AbCip.md` (sections: "Transport (`Transport` option)", "Native transport behaviour" covering connection sharing, MSP packing, the type check and press-safety limits, and "Known gaps" covering simulator-only writes, PLC-5/Micro800/routed paths, and libplctag deletion blocked on a bench controller) +- Modify: `docs/drivers/AbLegacy.md` (Transport section, PCCC simulator-only gap, PLC-5 status from Task 8, ST byte-order status from Task 14) +- Modify: `docs/drivers/AbServer-Test-Fixture.md` and `docs/drivers/AbLegacy-Test-Fixture.md` (suites now run per transport; the capture recipe `scripts/ab-capture/capture.sh`) +- Modify: `CLAUDE.md` (one short paragraph under a new heading "AB drivers: native transport (`Transport`)" with the switch, the confinement guard, and a pointer to the docs; keep it short, under 15 lines) +- Modify: `../scadaproj/.claude/skills/sister-project-history/otopcua.md` (append a dated entry), on scadaproj's `main` per the CLAUDE.md procedure + +**Acceptance Criteria:** +- [ ] `docs/drivers/AbCip.md` states explicitly: + - The native path reports `BadTypeMismatch` for a same-width mis-authored type that libplctag reads as Good garbage, with an example (DINT authored, REAL returned). + - That is the fix working, and it is called out as client-visible. +- [ ] Every config key added (`transport`) is documented with its values, its default and string serialization. +- [ ] The scadaproj history entry is committed on `main` and pushed. The OtOpcUa index **row** in `../scadaproj/CLAUDE.md` is **not** touched; the summary does not change, since the driver set and wire contract are the same. +- [ ] The scadaproj branch is restored afterwards. + +**Verify:** `grep -n "Transport" docs/drivers/AbCip.md docs/drivers/AbLegacy.md CLAUDE.md` shows the new sections; `cd ../scadaproj && git log origin/main -1 --format=%s` shows the history commit. + +**Steps:** + +- [ ] **Step 1: Write the repo docs** listed under **Files**. Take PLC-5 status from Task 8, ST byte-order status from Task 14, and the `/run` evidence from the gate-results doc. +- [ ] **Step 2: Do the scadaproj edit with the exact CLAUDE.md sequence:** + +```bash +cd ~/Desktop/scadaproj +ORIG=$(git rev-parse --abbrev-ref HEAD); git stash list +git checkout main && git pull +# append to .claude/skills/sister-project-history/otopcua.md +git commit -am "docs(history): OtOpcUa AbCip/AbLegacy gain a native managed transport behind a Transport switch" +git push origin main +git checkout "$ORIG" +``` + +- [ ] **Step 3: Commit in OtOpcUa.** `git add docs/drivers CLAUDE.md && git commit -m "docs(ab): native transport, Transport switch, known gaps and the type-check change"` + +--- + +### Task 21: Real-press read-only gate (CR CompactLogix) + +**Goal:** Run the Task 18 differential harness and a latency comparison against one production CompactLogix 5069-L320ERS2 press at CR, **read-only**, and record the evidence that decides the AbCip default flip. + +> **USER-ORDERED GATE — NON-SKIPPABLE.** This task was requested by the user in the current conversation. It MUST NOT be closed by walking around it, by declaring it "verified inline", or by substituting a cheaper check. Close only after every item in `acceptanceCriteria` has been re-validated independently, with output captured. + +**Files:** +- Modify: `docs/plans/2026-10-07-native-ab-transport-gate-results.md` (append the press results) +- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs` + +**Acceptance Criteria:** +- [ ] **Preconditions (human):** the VPN to CR is up, the user has OK'd touching the press at this time, and the target is **one** press. The default is Z-35162 at `10.209.70.160:44818`, path `1,0`; any other press must be named by the user. +- [ ] **Run the differential:** + - `AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_DIFF_FAMILY=CompactLogix AB_DIFF_COUNT_OPENS=1 dotnet test …--filter "FullyQualifiedName~Differential"` passes. + - Browse parity holds: the same discovered tag set (the 2026-10-07 run saw ~23,412 variables across 80 programs) and identical UDT shapes. + - Read parity holds over `AB_DIFF_MAX_TAGS=200`, with zero unexpected divergences. Every `TypeMismatch` expected-divergence is listed by tag name. + - Native made exactly **1** Forward Open over the run. That is achievable only because T18's harness holds an anchor lease for the whole run; if the count is higher, the harness is broken, not the press. +- [ ] **Latency comparison** (`PollLatencyComparisonTests`, env-gated by the same variables): + - Read a fixed set of 100 atomic tags 30 times per transport, sequentially; transports are never concurrent. The native side runs under an anchor lease, as in T18. + - Native passes issue all 100 reads **concurrently** (`Task.WhenAll`), so the shared scheduler can MSP-pack them. libplctag passes do the same; libplctag packs internally. + - Record the CIP round-trips per pass (native: `CipConnection.RequestsSent` from Task 9, delta per pass; libplctag: report n/a) and the p50/p95 wall time per pass. + - Pass criterion: native p95 ≤ libplctag p95 × 1.10. +- [ ] **No writes:** + - Evidence: the Task 18 source guard passes. + - `grep -c WriteAsync` over both differential test files returns 0. +- [ ] **Recorded results:** under the `## Real press (T21)` and `## Latency (T21)` headings, the results doc carries the raw `dotnet test` output, the divergence table, the latency table, the Forward Open count, the date/time (UTC) of the run and the press ID. +- [ ] **Failure handling:** if any criterion fails, the task stays open. Fix the code (with a pinning test), re-run the ab_server suites (Task 17) and the press run. **Do not** relax the criterion. + +**Verify:** `AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_DIFF_FAMILY=CompactLogix AB_DIFF_COUNT_OPENS=1 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --filter "FullyQualifiedName~Differential" --logger "console;verbosity=detailed"` → passed, 0 unexpected divergences, `ForwardOpenCount=1`, and native p95 ≤ 1.10 × libplctag p95. + +**Steps:** + +- [ ] **Step 1:** Write `PollLatencyComparisonTests` (skips without the env vars). Run it against ab_server first to prove it works. +- [ ] **Step 2:** Ask the user to confirm the VPN, the press and the time window. Do not proceed without an explicit yes. +- [ ] **Step 3:** Run the differential, then the latency comparison, **sequentially**. +- [ ] **Step 4:** Paste the evidence into the results doc. +- [ ] **Step 5: Commit.** `git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential docs/plans/2026-10-07-native-ab-transport-gate-results.md && git commit -m "test(abcip): real-press read-only gate — native vs libplctag differential and latency (Z-35162)"` + +--- + +### Task 22: Flip the defaults to Native + release note + +**Goal:** Make `Native` the default transport for both drivers (spec §8 P5), keeping `Libplctag` selectable, and write the client-visible release note. + +**Files:** +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs` (default `AbCipTransport.Native`) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs` (`fallback: AbCipTransport.Native`) +- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs` (same) +- Modify: both AdminUI forms (relabel the options as "Native (default)" / "libplctag (fallback)") +- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs`, `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs` (the absent ⇒ Native expectation) +- Modify: the CLIs' `--transport` default (`native`) +- Modify: `docs/drivers/AbCip.md`, `docs/drivers/AbLegacy.md` (the default, and a "Release note" subsection) + +**Acceptance Criteria:** +- [ ] Blocked by Task 21 passing **and** Task 17 green for all AbLegacy profiles. +- [ ] A config with no `transport` key runs Native in both drivers; prove it with the switch tests. +- [ ] Existing unit tests that construct `AbCipDriver` with **no** injected factory and expect libplctag behaviour are updated to set `Transport = Libplctag` explicitly. Find them with `grep -rn "new AbCipDriver(" tests | grep -v tagFactory`. +- [ ] The release note says: + - The native transport is the default. + - It reports `BadTypeMismatch` for mis-authored same-width types. + - Set `"transport": "Libplctag"` to fall back. + - AbCip writes and all of AbLegacy are simulator-proven only. +- [ ] The full `dotnet test ZB.MOM.WW.OtOpcUa.slnx` passes, except suites that need fixtures that are not up; those are listed as skipped. + +**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests` → all pass. + +**Steps:** + +- [ ] **Step 1: Write the failing tests.** Flip the switch tests' absent ⇒ X expectation to `Native` in `AbCipTransportSwitchTests` and `AbLegacyTransportSwitchTests`. Run them and confirm they fail. +- [ ] **Step 2: Flip the defaults.** Change the four defaults (two options classes, two `fallback:` arguments), relabel the AdminUI options, and change the CLIs' default to `native`. Run the switch tests → pass. +- [ ] **Step 3: Pin the remaining libplctag tests.** Find every test that builds a driver with no injected factory and relies on libplctag behaviour (`grep -rn "new AbCipDriver(" tests | grep -v tagFactory`, and the same for `AbLegacyDriver`), and set `Transport = Libplctag` on each. +- [ ] **Step 4: Write the release-note subsection** in both driver docs. +- [ ] **Step 5: Run the full suite.** +- [ ] **Step 6: Commit.** `git add src tests docs && git commit -m "feat(ab): native managed transport is the default for AbCip and AbLegacy; libplctag remains as fallback"` + +--- + +## Self-review notes (author) + +- **Spec coverage:** + - D1–D9 → T1–T16. + - §5 status mapping → T1, T2, T3. + - §5 press safety → T9 (throttle, storm test), T18 (single connection), T21 (one press, read-only). + - §6 packaging → T1, T11. + - §7.1 golden frames → T4–T8. + - §7.2 scheduler and connection tests → T9, T10. + - §7.3 both-transport suites → T17. + - §7.4 differential harness → T18, T21. + - §7.5 AdminUI `/run` → T19. + - §8 P5 flip → T22. + - P6 is out of plan, as recorded. +- **Deviations from the spec:** + - `MaxInFlight` is fixed at 1 (see Global Constraints). + - `LibplctagTagEnumerator` is renamed `SymbolListingEnumerator` (T13), because its decode loop is transport-neutral. + - `AbCipTagCreateParams` gains `AuthoredType` and `UseConnectedMessaging` (T12). + - Explicit `CipRouting` modes (T9): the spec assumed only connected-or-UCMM, but libplctag's PCCC framing needs a third, `Direct`. + - **Fable review (2026-10-07)** findings B1–B3 and 4–22 are folded in: the scheduler is per connection, caller cancellation is non-faulting, routing modes, the T5←T4 and T19←T17 dependencies, the narrowed guard regex, the 0x08 Large-FO fallback, keep-alive, the anchor lease, registration disposal, the BOOL 0xD3 exemption, PCCC chunking, `FromCip` parity, fixture locations, fragment continuation, the open-pair race, extra test files, the profile-driven routing flag, reinit semantics, the AbLegacy matrix filter, per-task models, and the bounded lease dispose. diff --git a/docs/plans/2026-10-07-native-ab-transport.md.tasks.json b/docs/plans/2026-10-07-native-ab-transport.md.tasks.json new file mode 100644 index 00000000..091fed4c --- /dev/null +++ b/docs/plans/2026-10-07-native-ab-transport.md.tasks.json @@ -0,0 +1,216 @@ +{ + "planPath": "docs/plans/2026-10-07-native-ab-transport.md", + "specPath": "docs/plans/2026-10-07-native-ab-transport-design.md", + "tasks": [ + { + "id": 1, + "subject": "Task 1: Ab.Transport project skeleton + shared contracts", + "status": "completed", + "description": "**Goal:** Create `ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport` and its test project, wired into the solution. It carries the shared vocabulary that every later task codes against: `AbStatus`, `AbTransportException`, `AbStatusCodes`, `CipWriter`/`CipReader`, `CipReply`, and `ICipChannel`.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatus.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbTransportException.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipWriter.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipReader.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipReply.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ICipChannel.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Wire/CipReplyTests.cs`\n- Create: `docs/plans/2026-10-07-native-ab-transport-gate-results.md` (skeleton with five empty headed sections, `## ab_server suites (T17)`, `## Differential vs ab_server (T18)`, `## AdminUI /run (T19)`, `## Real press (T21)` and `## Latency (T21)`, so later tasks append to separate sections and merge cleanly)\n- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add both projects next to the AbCip entries, lines 41-44 and 112-115)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj` and `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj` (add a `ProjectReference` to Ab.Transport)\n\n**Acceptance Criteria:**\n- [ ] `dotnet build ZB.MOM.WW.OtOpcUa.slnx` succeeds with 0 warnings and both new projects appear in the slnx.\n- [ ] `AbStatus.Ok == 0`, and every error member is negative.\n- [ ] `AbStatusCodes.FromCip` maps CIP general status as follows. It is aligned with the existing `AbCipStatusMapper.MapCipGeneralStatus`, so that existing mapping does not regress (review finding 13).\n - `0x00`/`0x0B` \u2192 `Ok`\n - `0x04`/`0x05`/`0x16` \u2192 `NotFound`\n - `0x06` \u2192 `Partial`\n - `0x08` \u2192 `Unsupported`\n - `0x0A` \u2192 `OutOfBounds`\n - `0x0C`/`0x10` \u2192 `DeviceStateConflict`\n - `0x0E`/`0x0F` \u2192 `NotAllowed`\n - `0x13` \u2192 `TooSmall`\n - `0x15` \u2192 `TooLarge`\n - `0xFF` + `0x2105` \u2192 `OutOfBounds`\n - `0xFF` + `0x2107` \u2192 `TypeMismatch`\n - everything else, including `0x1E`, \u2192 `BadReply`\n `0x1E` (embedded service error) is only meaningful on an MSP envelope, and the scheduler (T10) handles it before calling `FromCip`.\n- [ ] `AbStatusCodes.FromPccc` maps STS `0x00\u2192Ok`, `0x10\u2192Unsupported`, `0x20\u2192NotFound`, `0x30\u2192NotAllowed`, `0x40/0x50\u2192DeviceStateConflict`, and `0xF0` maps via EXT STS (`0x06/0x07\u2192NotFound` address problems, else `BadReply`).\n- [ ] `CipReply.Parse` round-trips a reply with 0, 1 and 2 extended-status words.\n- [ ] The driver projects reference Ab.Transport, and Ab.Transport references no OtOpcUa project.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests` \u2192 all pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 1.\n\n```json:metadata\n{\"files\": [\"ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport\", \"ZB.MOM.WW.OtOpcUa.slnx\", \"docs/plans/2026-10-07-native-ab-transport-gate-results.md\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatus.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbTransportException.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipReply.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ICipChannel.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipReader.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipWriter.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Wire/CipReplyTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 6}\n```" + }, + { + "id": 2, + "subject": "Task 2: AbCip seam \u2014 decouple the driver from libplctag type", + "status": "completed", + "description": "**Goal:** Change every AbCip file other than the `Libplctag*.cs` adapters so it speaks `AbStatus` / `AbTransportException` instead of libplctag's `Status` / `LibPlcTagException`. Behaviour stays identical.\n\n**Files:**\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs`, `LibplctagTagEnumerator.cs`, `LibplctagTemplateReader.cs` (wrap libplctag calls; translate)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs`\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs` (replace `MapLibplctagStatus(Status)` / `TryGetStatus(LibPlcTagException)` with `MapStatus(AbStatus)`, `MapStatus(int)`, and `MapException`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs` (`IsReachableStatus(AbStatus)`, `IsReachableException(AbTransportException)`; construct runtimes via an `IAbCipTagFactory` parameter that defaults to `new LibplctagTagFactory()`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:1,1186-1191` (catch `AbTransportException`)\n- Modify (tests): `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs`, `AbCipDriverProbeTests.cs`, `AbCipDriverReadTests.cs`, `AbCipStatusMapperTests.cs`, `AbCipDriverCodeReviewRegressionTests.cs` (line 326 uses `libplctag.Status.ErrorBadConnection`), `AbCipUnknownTagReadTests.cs`, `AbCipLoggingTests.cs` (line 91, same), `FakeAbCipTag.cs`. Run `grep -rln \"libplctag\" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --include=*.cs` and migrate **every** hit except `LibplctagStatusTranslationTests.cs` and the mapper parity oracle.\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs` (its `` must become `MapStatus(int)`. A dangling cref is CS1574, which `TreatWarningsAsErrors` turns into a build error.)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs`\n\n**Acceptance Criteria:**\n- [ ] `grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/*.cs` lists only `LibplctagTagRuntime.cs`, `LibplctagTagEnumerator.cs`, `LibplctagTemplateReader.cs` and `LibplctagStatusTranslation.cs`.\n- [ ] `LibplctagTagRuntime.GetStatus()` returns `(int)AbStatus`, and every libplctag call that can throw `LibPlcTagException` rethrows `AbTransportException` with the translated status and the original as `InnerException`.\n- [ ] The translation table is the existing `MapLibplctagStatus` buckets re-expressed in `AbStatus`:\n - `Ok\u2192Ok`, `Pending\u2192Pending`, `ErrorTimeout\u2192Timeout`\n - `ErrorNotFound/ErrorNoMatch/ErrorBadDevice\u2192NotFound`\n - `ErrorNotAllowed\u2192NotAllowed`\n - `ErrorOutOfBounds\u2192OutOfBounds`, `ErrorTooLarge\u2192TooLarge`, `ErrorTooSmall\u2192TooSmall`\n - `ErrorUnsupported/ErrorNotImplemented\u2192Unsupported`\n - `ErrorPartial\u2192Partial`, `ErrorAbort\u2192Abort`\n - `ErrorBadReply\u2192BadReply`\n - every other member \u2192 `BadConnection`\n- [ ] `AbCipStatusMapper.MapStatus(AbStatus)` produces exactly the OPC UA codes the old mapper produced for the corresponding libplctag members. `TypeMismatch` maps to `BadTypeMismatch` and `DeviceStateConflict` to `BadDeviceFailure`. `Partial` and `BadReply` must still map to `BadCommunicationError`, as `ErrorPartial` / `ErrorBadReply` did.\n- [ ] The full existing `AbCip.Tests` suite is green, with the only changes being test fakes constructing `AbTransportException(AbStatus.X, ...)` instead of `LibPlcTagException(\"ErrorX\")` and setting `Status = (int)AbStatus.X`.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests` \u2192 all pass, then `grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/*.cs` \u2192 exactly the four `Libplctag*` files.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 2.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:1,1186-1191\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests && grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/*.cs\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 5}\n```", + "blockedBy": [ + 1 + ] + }, + { + "id": 3, + "subject": "Task 3: AbLegacy seam \u2014 decouple the driver from libplctag t", + "status": "completed", + "description": "**Goal:** Do the same as Task 2 for AbLegacy. Only `LibplctagLegacyTagRuntime.cs` and `LibplctagLegacyStatusTranslation.cs` may reference libplctag.\n\n**Files:**\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs`\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyStatusMapper.cs` (`MapStatus(AbStatus|int)`, `MapException`; keep `MapPcccStatus`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs` (`IsReachableStatus(AbStatus)`, catch `AbTransportException`, optional `IAbLegacyTagFactory` parameter defaulting to `new LibplctagLegacyTagFactory()`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs` (lines ~318-325: `MapLibplctagStatus`\u2192`MapStatus`; log text \"transport status\"; any `LibPlcTagException` catch \u2192 `AbTransportException`)\n- Modify (tests): `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyEvictOnFailureTests.cs`, `AbLegacyReadWriteTests.cs` (line 73), `AbLegacyHostAndStatusTests.cs`, `AbLegacyBitRmwTests.cs` (line 232), `AbLegacyArrayTests.cs` (line 115), `FakeAbLegacyTag.cs`. Run `grep -rln \"libplctag\" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --include=*.cs` and migrate every hit except the translation test and the parity oracle. Also grep `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy` for `cref=\".*MapLibplctagStatus` and fix any match (CS1574).\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyStatusTranslationTests.cs`\n\n**Acceptance Criteria:**\n- [ ] `grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/*.cs` lists exactly `LibplctagLegacyTagRuntime.cs` and `LibplctagLegacyStatusTranslation.cs`.\n- [ ] The translation table is identical to Task 2's, applied in the AbLegacy namespace. The table is duplicated, not shared, because each driver owns its own libplctag confinement and Task 22's eventual deletion removes both.\n- [ ] `AbLegacyStatusMapper.MapStatus(AbStatus)` gives exactly the outputs the old `MapLibplctagStatus` gave for the corresponding members, proven by a parity table copied from the old switch before deleting it.\n- [ ] The full `AbLegacy.Tests` suite is green.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests` \u2192 all pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 3.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyStatusMapper.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyEvictOnFailureTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyStatusTranslationTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 4}\n```", + "blockedBy": [ + 1 + ] + }, + { + "id": 4, + "subject": "Task 4: Capture golden frames (libplctag \u21c4 ab_server)", + "status": "completed", + "description": "**Goal:** Capture, once, the exact request/reply bytes that libplctag 1.5.2 exchanges with `ab_server` for every operation the native stack must reproduce. Store them as hex fixtures that Tasks 7 and 8 assert parity against.\n\n**Files:**\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/README.md` (how each capture was taken: commit, image tag, command)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/*.hex` (one file per exchange, as below)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFile.cs` (loader)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFileTests.cs`\n- Create: `scripts/ab-capture/capture.sh` (re-runnable recipe)\n\n**Acceptance Criteria:**\n- [ ] Each `.hex` file holds alternating `> ` (client\u2192server) and `< ` (server\u2192client) lines of **encapsulation frames**. Each line is one full EIP frame (the 24-byte header plus data) as uppercase hex with no spaces. Lines starting with `#` are comments.\n- [ ] These captures exist and are non-empty:\n - `logix-register-session.hex`\n - `logix-forward-open.hex` (both a Large FO 0x5B and, if libplctag falls back or uses it, a 0x54)\n - `logix-read-dint.hex`, `logix-read-dint-array16.hex`, `logix-read-string.hex`, `logix-read-bool.hex`\n - `logix-write-dint.hex`, `logix-write-string.hex`\n - `logix-read-unknown-tag.hex` (CIP 0x04, via patch 0002)\n - `logix-tags-listing.hex` (`@tags`, via patch 0001)\n - `logix-forward-close.hex`\n - `micro800-read-dint.hex` (UCMM, no Forward Open)\n - `slc500-read-n7.hex`, `slc500-write-n7.hex`, `slc500-read-f8.hex`, `micrologix-read-n7.hex`, `plc5-read-n7.hex`\n- [ ] `CaptureFileTests` loads every `.hex` file, parses every frame header, and asserts that each frame's encapsulation length field equals its data length.\n- [ ] `Captures/README.md` records, for each file: the libplctag version (1.5.2), the ab_server image (`otopcua-ab-server:libplctag-release`), the compose profile, and the libplctag tag string used.\n- [ ] `Captures/README.md` has a **\"Routing observed\"** table, one row per family (controllogix, micro800, slc500, micrologix, plc5, plus logixpccc if reachable), with the columns: ForwardOpen yes/no \u00b7 CIP request wrapped in Unconnected Send yes/no \u00b7 route bytes \u00b7 requestor-id bytes (PCCC). Tasks 9, 12 and 14 read their `Routing` choice from this table.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter CaptureFile` \u2192 pass, and `ls tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/*.hex | wc -l` \u2192 \u2265 17.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 4.\n\n```json:metadata\n{\"files\": [\"scripts/ab-capture/capture.sh\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/*.hex\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFile.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFileTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/README.md\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter CaptureFile && ls tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/*.hex | wc -l\", \"modelTier\": \"frontier\", \"model\": \"opus\", \"acceptanceCriteriaCount\": 5}\n```", + "blockedBy": [ + 1 + ] + }, + { + "id": 5, + "subject": "Task 5: EtherNet/IP encapsulation codec + `EncapsulationClie", + "status": "completed", + "description": "**Goal:** A TCP client that frames EIP encapsulation, registers a session, and sends SendRRData (unconnected) and SendUnitData (connected) requests with at most one outstanding request. It also includes a scriptable in-process fake server for tests.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationHeader.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationCodec.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationClient.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationCodecTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationClientTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeEipServer.cs`\n\n**Acceptance Criteria:**\n- [ ] `EncapsulationCodec.RegisterSession()` produces exactly `65 00 04 00 00000000 00000000 <8-byte context> 00000000 01 00 00 00`.\n- [ ] `EncapsulationCodec.SendRRData(session, ctx, cip, timeoutSec)` produces: header with command `0x006F`; data = interface handle `00000000`, timeout `u16`, item count `02 00`, Null Address item `00 00 00 00`, then the Unconnected Data item `B2 00 ` followed by the cip bytes.\n- [ ] `EncapsulationCodec.SendUnitData(session, connId, seq, cip)` produces: command `0x0070`; data = interface handle `00000000`, timeout `00 00`, item count `02 00`, Connected Address item `A1 00 04 00 `, Connected Data item `B1 00 ` followed by the cip bytes.\n- [ ] `EncapsulationCodec.ParseReply` returns the header and the CPF item payload. For a connected reply it strips the sequence count. It throws `AbTransportException(BadReply)` when encapsulation status \u2260 0, the length is wrong, or the expected item is missing.\n- [ ] `EncapsulationClient`:\n - **Lifecycle.** It connects, registers (and stores the session handle from the reply), and `UnRegisterSession`s on `DisposeAsync`.\n - **Single flight.** It serializes requests with a `SemaphoreSlim(1,1)`.\n - **Connected sequence (B1).** `SendUnitDataAsync(otConnectionId, cip, timeout, ct)` takes **no sequence argument**. The client owns the connected sequence counter and increments it **inside the gate**, wrapping 65535 \u2192 1.\n - **Matching replies.**\n - Unconnected replies match on the 8-byte sender context.\n - Connected (0x70) replies match on the echoed sequence count.\n - Any frame that doesn't match (an earlier abandoned request's late reply) is **skipped** and the client keeps reading. It is not treated as an error.\n - **Caller cancellation (B2).**\n - The frame write uses `CancellationToken.None`, so a write is never half-sent.\n - If the *caller's* token is cancelled during the reply wait, the call throws `OperationCanceledException`. The connection is **not** faulted: `IsConnected` stays true, the gate is released, and the late reply is skipped by the next exchange.\n - **Faults.**\n - The *timeout* elapsing (no reply within `timeout`) throws `AbTransportException(Timeout)` and **faults** the connection by closing the socket. A target that has stopped answering cannot be trusted.\n - A socket or I/O error throws `AbTransportException(BadConnection)` and faults the connection.\n - `IsConnected` turns false after any fault.\n- [ ] Parity: the bytes `RegisterSession` sends, apart from the sender context, equal the first `>` frame of `Captures/logix-register-session.hex`.\n- [ ] `FakeEipServer` accepts a script of `Func` steps, records every request it receives, and can be told to stall (to test timeouts) or drop the connection.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \"FullyQualifiedName~Eip\"` \u2192 all pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 5.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationClient.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationCodec.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationHeader.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationClientTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationCodecTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeEipServer.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \\\"FullyQualifiedName~Eip\\\"\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 7}\n```", + "blockedBy": [ + 1, + 4 + ] + }, + { + "id": 6, + "subject": "Task 6: CIP path encoding + Logix type codes", + "status": "completed", + "description": "**Goal:** Pure encoders for (a) the route from a CIP path string such as `1,0` and (b) the request path for a Logix symbolic tag name. Also provide the Logix atomic type-code table that the native runtime uses for writes and for the type check.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTypeCode.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipPathTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTypeCodeTests.cs`\n\n**Acceptance Criteria:**\n- [ ] `CipPath.EncodeRoute(\"1,0\")` \u2192 `01 00`. `EncodeRoute(\"\")` \u2192 empty. `EncodeRoute(\"1,0,2,10.0.0.5\")` \u2192 `01 00 12 08 31 30 2E 30 2E 30 2E 35`. That is port 2 with the extended-link-address flag `0x10 | 2` = `0x12`, length 8, the ASCII text, and no pad because the length is even. A 9-character address gets one pad byte. Port numbers \u2265 15 use the extended-port form `0x0F, u16 port`.\n- [ ] `CipPath.EncodeSymbolic(\"TestDINT\")` \u2192 `91 08 54 65 73 74 44 49 4E 54`. An odd-length name gets a pad byte after the name.\n- [ ] `EncodeSymbolic(\"Program:Main.Arr[3].Val\")` \u2192 `91 0C \"Program:Main\"`, then `91 03 \"Arr\" 00`, then element `28 03`, then `91 03 \"Val\" 00`.\n- [ ] Element segments use `28 xx` for indices < 256, `29 00 lo hi` for indices < 65536, and `2A 00 b0 b1 b2 b3` above that. A multi-dimensional `[1,2]` emits two element segments.\n- [ ] A trailing purely numeric member (`Tag.5`, the bit selector) is **not** encoded. `EncodeSymbolic` throws `ArgumentException`, and `CipPath.SplitBitSelector(\"Tag.5\")` returns `(\"Tag\", 5)`. Bit selection is the runtime's job (Task 12).\n- [ ] `CipPath.EncodeClassInstance(cls, inst)` uses 8-bit logical segments (`20 cc 24 ii`) when they fit, otherwise 16-bit (`21 00 cc cc` / `25 00 ii ii`).\n- [ ] `CipPath.WithPathSize(segments)` prefixes the size in 16-bit words.\n- [ ] `LogixTypeCode` has these constants: `Bool=0xC1, SInt=0xC2, Int=0xC3, DInt=0xC4, LInt=0xC5, USInt=0xC6, UInt=0xC7, UDInt=0xC8, ULInt=0xC9, Real=0xCA, LReal=0xCB, Struct=0x02A0, BoolArray=0xD3`. `ByteWidth(code)` returns 1/1/2/4/8/1/2/4/8/4/8/4.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \"FullyQualifiedName~CipPath|FullyQualifiedName~LogixTypeCode\"` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 6.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTypeCode.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipPathTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTypeCodeTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \\\"FullyQualifiedName~CipPath|FullyQualifiedName~LogixTypeCode\\\"\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 8}\n```", + "blockedBy": [ + 1 + ] + }, + { + "id": 7, + "subject": "Task 7: CIP service codecs (Connection Manager, MSP, Logix R", + "status": "completed", + "description": "**Goal:** Pure request builders and reply parsers for every CIP service the native AbCip path needs, each proven byte-exact against the Task 4 captures where a capture exists.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs` (Unconnected Send 0x52, Forward Open 0x54, Large Forward Open 0x5B, Forward Close 0x4E)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/MultipleServicePacket.cs` (0x0A)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixServices.cs` (Read Tag 0x4C, Read Tag Fragmented 0x52, Write Tag 0x4D, Write Tag Fragmented 0x53)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/SymbolObjectServices.cs` (Get Instance Attribute List 0x55 on class 0x6B)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/TemplateObjectServices.cs` (Get Attribute List 0x03 + Read 0x4C on class 0x6C)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ConnectionManagerServicesTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/MultipleServicePacketTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixServicesTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/SymbolObjectServicesTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/TemplateObjectServicesTests.cs`\n\n**Acceptance Criteria:**\n- [ ] **Unconnected Send** `0x52` to `ConnectionManager` builds: priority/tick `0x0A`, timeout ticks `0x0E`, embedded request size `u16`, the embedded request, a pad byte if that size is odd, the route path size in words `u8`, reserved `0x00`, then the route. When the route is empty (Micro800), the builder is not used and the embedded request is sent directly.\n- [ ] **Forward Open.** `ForwardOpen(ForwardOpenParams p)` emits:\n - service `0x54` (or `0x5B` when `p.Large`), path size `02`, `ConnectionManager`, priority/tick `0x0A`, timeout ticks `0x0E`;\n - O\u2192T id `00000000`, T\u2192O id `p.TOConnectionId`, connection serial `p.ConnectionSerial`, vendor id `p.VendorId`, originator serial `p.OriginatorSerial`;\n - timeout multiplier `p.TimeoutMultiplier`, 3 reserved bytes;\n - O\u2192T RPI `u32`, then O\u2192T params: `u16 0x4200 | size` (regular) or `u32 0x42000000 | size` (large);\n - T\u2192O RPI, then T\u2192O params (same encoding);\n - transport/trigger `0xA3`, connection path size in words, connection path = `EncodeRoute(cipPath)` + `MessageRouter`.\n- [ ] `ParseForwardOpenReply` returns `(OTConnectionId, TOConnectionId, OTApiMicros, TOApiMicros)` on success. On a refusal it surfaces the general and extended status.\n- [ ] `ShouldFallBackToRegularOpen(CipReply)` is true when the reply is general `0x08` (Service Not Supported \u2014 pre-v20 Logix and other targets answer a Large Forward Open this way, and libplctag falls back on it too) **or** general `0x01` with extended `0x0109` (invalid connection size). Task 9 uses it.\n- [ ] `ForwardClose(serial, vendor, origSerial, route)` \u2192 `4E 02 20 06 24 01 0A 0E 00 `.\n- [ ] **MSP.** `MultipleServicePacket.Build(IReadOnlyList requests)` \u2192 `0A 02 20 02 24 01`, count `u16`, then offsets `u16[count]` measured from the start of the count field, then the requests concatenated. `Parse(reply, count)` returns one `CipReply` per embedded reply. `BuildSize(sizes)` returns the exact byte size of an MSP for the given request sizes, without allocating (Task 10 uses it for packing).\n- [ ] **Read/Write Tag:**\n - `ReadTag(path, elements)` \u2192 `4C `.\n - `ReadTagFragmented(path, elements, offset)` \u2192 `52 \u2026 `.\n - `WriteTag(path, typeCode, structHandle?, elements, data)` \u2192 `4D \u2026 [ when type == 0x02A0] `.\n - `WriteTagFragmented` \u2192 `53 \u2026 [] `.\n - `ParseReadReply(CipReply)` \u2192 `(ushort TypeCode, ushort? StructHandle, ReadOnlyMemory Data)`. For `0x02A0` the next u16 is the handle.\n- [ ] **Symbol listing.** `SymbolObjectServices.GetInstanceAttributeList(programName?, startInstance)` \u2192\n - `[91 len \"Program:X\" pad]`, then `20 6B 25 00 `, then attribute count `04 00` and attributes `02 00 07 00 08 00 01 00` (type, element length, array dims, name).\n - Each reply entry, as the controller sends it, is `u32 instance, u16 type, u16 elemLen, u32\u00d73 dims, u16 nameLen, name` \u2014 exactly the entry layout `CipSymbolObjectDecoder` expects. `ParseListingReply` returns `(byte[] Entries, uint LastInstance, bool More)`, where More \u21d4 general status `0x06`.\n - \u26a0\ufe0f If `Captures/logix-tags-listing.hex` shows libplctag requesting a different attribute set or order, **match the capture** and update this criterion. The decoder layout is the invariant; the attribute list is the means.\n- [ ] **Templates:**\n - `TemplateObjectServices.GetAttributes(templateId)` \u2192 `03 03 20 6C 25 00 04 00 04 00 05 00 02 00 01 00`. That is attributes 4 (definition size, words), 5 (structure size, bytes), 2 (member count) and 1 (handle).\n - `ParseAttributes` \u2192 `(uint DefWords, uint StructBytes, ushort Members, ushort Handle)`; each attribute's reply is `id u16, status u16, value`.\n - `ReadTemplate(templateId, offset, bytes)` \u2192 `4C 03 20 6C 25 00 `, with More \u21d4 status `0x06`.\n - `TemplateObjectServices.BuildDecoderBuffer(id, attrs, rawTemplate)` \u2192 the 14-byte header + raw bytes from Global Constraints.\n- [ ] **Capture parity tests** (each extracts the CIP payload via `CaptureFile.CipPayload`):\n - The ReadTag request in `logix-read-dint.hex` equals our `ReadTag(EncodeSymbolic(\"TestDINT\"), 1)`. If libplctag wrapped it in an MSP or Unconnected Send, unwrap the same way in the test, and document that wrapping in a test comment.\n - `ParseReadReply` on the captured reply yields `TypeCode == 0xC4` and 4 data bytes.\n - The captured Forward Open request equals ours when built with the captured values of the serials and connection ids, which are read out of the capture.\n - The captured `@tags` reply entries decode through `CipSymbolObjectDecoder` (reference the AbCip project from the test project only if that is needed; otherwise assert the entry byte layout directly).\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \"FullyQualifiedName~Services|FullyQualifiedName~MultipleServicePacket\"` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 7.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/MultipleServicePacket.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixServices.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/SymbolObjectServices.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/TemplateObjectServices.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ConnectionManagerServicesTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/MultipleServicePacketTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixServicesTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/SymbolObjectServicesTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/TemplateObjectServicesTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \\\"FullyQualifiedName~Services|FullyQualifiedName~MultipleServicePacket\\\"\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 10}\n```", + "blockedBy": [ + 4, + 6 + ] + }, + { + "id": 8, + "subject": "Task 8: PCCC codec (Execute PCCC, SLC/MicroLogix typed logic", + "status": "completed", + "description": "**Goal:** Pure builders and parsers that turn a PCCC file address into an Execute-PCCC CIP request, for both SLC/MicroLogix/LogixPccc and PLC-5. Byte parity is proven against the Task 4 captures.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccFileType.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccAddress.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccServices.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccAddressTests.cs`\n\n**Acceptance Criteria:**\n- [ ] `PcccAddress(string FileLetter, int? FileNumber, int Element, int? SubElement)` is the transport-side address. It does not depend on `AbLegacyAddress`; Task 14 maps between the two.\n- [ ] File type codes: `O=0x8B, I=0x8C, S=0x84, B=0x85, T=0x86, C=0x87, R=0x88, N=0x89, F=0x8A, ST=0x8D, A=0x8E, L=0x91`. Implicit file numbers: `O\u21920, I\u21921, S\u21922`. **Verify every code against `slc500-read-n7.hex` / `slc500-read-f8.hex`**; the capture wins if it disagrees.\n- [ ] Sub-element indices: `T/C` \u2192 `PRE=1, ACC=2` (word offsets within the 3-word element, where word 0 is the control bits), and `R` \u2192 `LEN=1, POS=2`. The bit sub-elements (`EN/TT/DN/CU/CD/OV/UN`) read word 0 and are returned as `(sub=0, bit)`. `PcccAddress.ResolveSubElement` returns `(int word, int? bit)`.\n- [ ] `PcccServices.ExecutePccc(requestorVendor, requestorSerial, pccc)` \u2192 `4B 02 20 67 24 01 07 `.\n- [ ] `PcccServices.SlcTypedRead(tns, addr, byteCount)` \u2192 PCCC `0F 00 A2 `, where `u8*` is a single byte when < 255, else `FF lo hi`.\n- [ ] `PcccServices.SlcTypedWrite(tns, addr, data)` \u2192 `0F 00 AA `.\n- [ ] **Byte-count limit (review finding 12).** `byteCount` is a single byte on the wire. `SlcTypedRead`/`SlcTypedWrite` (and the PLC-5 builders) throw `AbTransportException(TooLarge)` when the requested or supplied byte count exceeds `PcccServices.MaxDataBytes = 236`; a silent `(byte)` wrap would be a corrupt write. Test 236 (ok) and 237 (throws) for both. Task 14 chunks larger arrays.\n- [ ] `PcccServices.Plc5TypedRead(tns, addr, elements)` \u2192 `0F 00 68 `.\n - The system address is a level-mask byte (bit0 = file level present? **derive from `plc5-read-n7.hex`**) followed by level values (`$N7:0` \u21d2 data table section, file 7, element 0), each `u8*` encoded.\n - `Plc5TypedWrite` is `0F 00 67 `.\n - \u26a0\ufe0f The PLC-5 framing must be **copied from the capture**. If ab_server's `--plc=PLC/5` path is not exercised by libplctag in the capture, do **not** ship PLC-5 builders at all. Instead expose `PcccServices.Plc5Verified = false`, have the native AbLegacy runtime throw `AbTransportException(Unsupported)` for `plc5`, and record that in the Task 20 docs. Do not use `[Obsolete]`: under `TreatWarningsAsErrors` it is a build error at every call site.\n- [ ] `PcccServices.ParseReply(cipReply)` strips the requestor-id echo (`len u8` + `len-1` bytes), reads `cmd u8 (0x4F), sts u8, tns u16`, then `extSts u8` if `sts == 0xF0`, then the data. Non-zero STS \u2192 `AbTransportException(AbStatusCodes.FromPccc(sts, ext))`. A TNS that differs from the request \u2192 `BadReply`.\n- [ ] Capture parity: `SlcTypedRead` for `N7:0` / `F8:0` equals the PCCC payload inside the captured Execute PCCC request, with the TNS normalized; `ParseReply` on the captured reply yields the 2 / 4 data bytes.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \"FullyQualifiedName~Pccc\"` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 8.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccAddress.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccFileType.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccServices.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccAddressTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \\\"FullyQualifiedName~Pccc\\\"\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 10}\n```", + "blockedBy": [ + 4 + ] + }, + { + "id": 9, + "subject": "Task 9: CipConnection + DeviceSessionPool (routing modes, ke", + "status": "completed", + "description": "**Goal:** Own one EIP session, one CIP connection (when routed `Connected`) and **one `RequestScheduler`** per device key. Hand out ref-counted leases exposing both, keep idle class-3 connections alive, and rate-limit Forward Opens so that no failure mode can storm a PLC.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/DeviceKey.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionOptions.cs` (includes the `CipRouting` enum)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs` (implements `ICipChannel`)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ForwardOpenThrottle.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/DeviceSessionPool.cs` (includes `ConnectionLease`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj` (`InternalsVisibleTo` for `ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests`, `ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests`, `ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests`)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ForwardOpenThrottleTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/DeviceSessionPoolTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipTarget.cs` (a `FakeEipServer.Handler` that answers Forward Open / Forward Close / Unconnected Send / Direct / connected data, and counts each)\n\n**Acceptance Criteria:**\n- [ ] `DeviceKey(string Host, int Port, string CipPath, string PlcAttribute)` is a record with case-insensitive `Host`/`PlcAttribute` equality.\n- [ ] **`ConnectionOptions`:**\n - `Routing` (`CipRouting { Connected, UnconnectedSend, Direct }`) and `AllowPacking` (bool).\n - `Timeout` and `ConnectionSize` (int).\n - `MinForwardOpenInterval` (default 1 s).\n - `RpiMicros` (default 2 000 000) and `TimeoutMultiplier` (default 1 \u21d2 \u00d78, so connection expiry = 16 s).\n - `KeepAliveInterval` (default = RPI \u00d7 8 / 2 = 8 s).\n - `VendorId` (`0x1337`) and `OriginatorSerial` (random per process).\n- [ ] **Routing semantics (B3):**\n - `Connected`:\n - On first use: TCP connect, RegisterSession, Forward Open. When `ConnectionSize > 511` that is a Large FO (0x5B); otherwise a regular one.\n - If `ShouldFallBackToRegularOpen` (T7) is true for the reply, it retries **once**, in the same throttled attempt, with a regular open at `Math.Min(ConnectionSize, 504)`.\n - Requests go via `SendUnitDataAsync`. The client assigns the sequence (T5).\n - `MaxRequestBytes = negotiatedSize - 2`.\n - `UnconnectedSend`: requests go via SendRRData wrapped in Unconnected Send 0x52 with the route. `MaxRequestBytes = 504 - (12 + route.Length)`.\n - `Direct`: requests go via SendRRData carrying the CIP request bare, even when a CIP path is configured; the path is ignored. `MaxRequestBytes = 504`.\n- [ ] **Reconnect:** when the client is faulted (timeout or I/O, never caller cancellation \u2014 see T5), the next `SendAsync` reconnects through the throttle. The failing call itself throws its original exception.\n- [ ] **Keep-alive (review finding 8, spec \u00a79).** In `Connected` mode, a timer sends Get Attribute Single (`0E 03 20 01 24 01 30 01`: Identity, instance 1, attribute 1 = Vendor ID) through the connection whenever no request has been sent for `KeepAliveInterval`.\n - A keep-alive failure faults the connection like any other.\n - Keep-alives count towards `RequestsSent`.\n - Test with fake time: no keep-alive while traffic flows; exactly one after an idle `KeepAliveInterval`.\n- [ ] **No null-race (finding 16).** The open path returns the `(client, connection)` pair it established, and `SendAsync` uses that pair rather than re-reading fields after the lock.\n- [ ] **Clean disposal.** Disposal never disposes a `SemaphoreSlim` that waiters may still be queued on. It sets a `_disposed` flag checked under the lock, and later callers get `AbTransportException(Abort)`.\n- [ ] **`ForwardOpenThrottle`** (per `DeviceKey`; the pool keeps throttles for the process lifetime, so a reconnect after the last release is still limited):\n - At most 1 open attempt in flight.\n - An attempt within `MinForwardOpenInterval` of the previous **attempt start** waits for the remainder. If the caller's token fires first, it throws `AbTransportException(BadConnection, \"Forward Open throttled\")`.\n - It uses an injected `TimeProvider`.\n- [ ] **Storm test:** against a `FakeCipTarget` refusing every Forward Open, 50 concurrent `SendAsync` callers over 3 s of fake time produce **\u2264 4** Forward Open frames.\n- [ ] **`DeviceSessionPool`:**\n - `Acquire(DeviceKey, ConnectionOptions) \u2192 ConnectionLease`. `ConnectionLease : IAsyncDisposable` exposes `ICipChannel Channel` and `RequestScheduler Scheduler`.\n - **All leases for one key share one `CipConnection` and one `RequestScheduler`** (B1). The scheduler is built as `new RequestScheduler(connection, options.AllowPacking)`. A second `Acquire` with different `ConnectionOptions` for the same key reuses the first entry's options; that is logged once and pinned by a test.\n - The last lease's dispose disposes the scheduler, sends Forward Close (`Connected` only) and then UnRegisterSession.\n - A later acquire on the same key builds new instances.\n - Thread-safe: 100 parallel acquire/dispose tasks pass.\n- [ ] `DeviceSessionPool.Shared` is the process-wide instance; tests build their own.\n- [ ] **Diagnostics for the gates (Tasks 18 and 21):** `internal static int ForwardOpenCount` (one increment per Forward Open frame sent, including the fallback) and `internal static int RequestsSent` (one per channel send, keep-alives included), both via `Interlocked`.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \"FullyQualifiedName~CipConnection|FullyQualifiedName~ForwardOpenThrottle|FullyQualifiedName~DeviceSessionPool\"` \u2192 pass; the storm test passes 20/20 consecutive runs.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 9.\n\n```json:metadata\n{\"files\": [\"ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests\", \"ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests\", \"ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionOptions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/DeviceKey.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ForwardOpenThrottle.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/DeviceSessionPool.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ForwardOpenThrottleTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/DeviceSessionPoolTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipTarget.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \\\"FullyQualifiedName~CipConnection|FullyQualifiedName~ForwardOpenThrottle|FullyQualifiedName~DeviceSessionPool\\\"\", \"modelTier\": \"frontier\", \"model\": \"opus\", \"acceptanceCriteriaCount\": 12}\n```", + "blockedBy": [ + 5, + 7, + 10 + ] + }, + { + "id": 10, + "subject": "Task 10: RequestScheduler \u2014 MSP coalescing + fragmentation", + "status": "completed", + "description": "**Goal:** A per-channel scheduler that coalesces concurrent CIP requests into Multiple Service Packets within `MaxRequestBytes`, keeps one request in flight, fans each reply back to its own waiter, and runs fragmented read/write loops for payloads that do not fit in one frame.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/RequestScheduler.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTagClient.cs` (read/write helpers over the scheduler, including the fragment loops)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/RequestSchedulerTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTagClientTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipChannel.cs`\n\n**Acceptance Criteria:**\n- [ ] `RequestScheduler(ICipChannel channel, bool allowPacking)` exposes `Task SendAsync(byte[] request, CancellationToken ct)`.\n- [ ] **Packing:** while one request is in flight, new requests queue. When the channel frees, the scheduler takes queued requests in FIFO order while `MultipleServicePacket.BuildSize(sizes) <= channel.MaxRequestBytes`. More than one taken \u21d2 one MSP. Exactly one \u21d2 sent bare. `allowPacking == false` \u21d2 always bare.\n- [ ] **Boundary tests:** with `MaxRequestBytes = M`, two requests whose MSP size is exactly `M` pack into one frame, and two whose MSP size is `M + 1` go as two frames.\n- [ ] **A single request larger than `MaxRequestBytes`** is sent bare. Callers must fragment; `LogixTagClient` does.\n- [ ] **Reply fan-out:** each waiter gets its own embedded `CipReply`, with its own general status, so one failing service in an MSP fails only its own waiter. If the MSP envelope itself fails (non-zero general status other than `0x1E` embedded-service-error), or the channel throws, every waiter in that batch faults with the same `AbTransportException`.\n- [ ] **Cancellation:**\n - A waiter cancelled while queued is removed and never sent.\n - A waiter cancelled while its batch is in flight gets `OperationCanceledException`, and the batch's other waiters still complete.\n - **The channel call always uses the scheduler's own `_stop` token, never a waiter's**, for single sends as well as MSPs (B2). So one caller's cancellation can never abort, or fault, a shared exchange.\n - Each waiter's `CancellationTokenRegistration` is stored on its `Pending` record and **disposed when the TCS completes** (review finding 10). A test makes 10 000 sends on one long-lived token and asserts no growth: measure with a counting `CancellationTokenSource` subclass, or assert `Pending` registrations are disposed via a test hook.\n- [ ] **Owner:** one scheduler per device connection, created by `DeviceSessionPool` (T9). This task builds only the class and tests it over `FakeCipChannel`.\n- [ ] **`LogixTagClient`:**\n - `ReadAsync(path, elements)` sends Read Tag.\n - If the reply status is `0x06` (Partial), it **keeps** that reply's data and continues with Read Tag Fragmented at `offset = bytesReceivedSoFar`, appending each reply's data until a reply has status `0x00`. That avoids double-counting the first chunk (review finding 15).\n - It returns `(TypeCode, StructHandle, byte[] Data)`. The type code and handle come from the first reply; fragment replies repeat the type header, so strip it from each before appending.\n - **Reply-size note:** packing bounds only the *request* size, so many large reads packed together can produce a reply that the controller truncates with `0x06` per service. That is correct behaviour (the fragment loop recovers it), not an error.\n - `WriteAsync(path, type, handle, elements, data)` sends one Write Tag when it fits, otherwise Write Tag Fragmented chunks of `MaxRequestBytes - 12 - path.Length`, aligned down to the element width.\n - Non-zero final status \u21d2 `AbTransportException(reply.Status)`.\n- [ ] `FakeCipChannel` lets a test set `MaxRequestBytes`, records every request, and answers via a delegate. It is reused by Tasks 12 and 13.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \"FullyQualifiedName~RequestScheduler|FullyQualifiedName~LogixTagClient\"` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 10.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/RequestScheduler.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTagClient.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/RequestSchedulerTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipChannel.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTagClientTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \\\"FullyQualifiedName~RequestScheduler|FullyQualifiedName~LogixTagClient\\\"\", \"modelTier\": \"frontier\", \"model\": \"opus\", \"acceptanceCriteriaCount\": 9}\n```", + "blockedBy": [ + 7 + ] + }, + { + "id": 11, + "subject": "Task 11: libplctag-confinement guard", + "status": "completed", + "description": "**Goal:** A source-scan test that fails if any production `.cs` file outside the libplctag adapters references `libplctag`, so the eventual deletion stays mechanical.\n\n**Files:**\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/LibplctagConfinementGuardTests.cs`\n\n**Acceptance Criteria:**\n- [ ] The test walks up from `AppContext.BaseDirectory` to the directory containing `ZB.MOM.WW.OtOpcUa.slnx`, then scans every `*.cs` under `src/` (excluding `bin`/`obj`) for **code** references: `using libplctag` or `libplctag.(Tag|Status|PlcType|Protocol|LibPlcTagException|NativeImport)`. Prose such as the CLI help string `\"via libplctag. Second of six\u2026\"` (`AbCip.Cli/Program.cs:9`, `AbLegacy.Cli/Program.cs:8`) must **not** match, or the guard is red from wave 2 until T15/T16 (review finding 6).\n- [ ] Allowed files: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Libplctag*.cs` and `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/Libplctag*.cs` only. Comments and XML docs are ignored by stripping `//` line comments before matching.\n- [ ] The failure message lists each offending file and line.\n- [ ] A second fact, `Prose_mentions_of_libplctag_do_not_trip_the_guard`, runs the regex against the literal `\"\u2026via libplctag. Second of six driver CLIs\"` and asserts no match.\n- [ ] **Falsifiability check:** temporarily add `using libplctag;` to `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs`, run the test and see it go red, then revert. Record that you did this in the commit message.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter LibplctagConfinementGuard` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 11.\n\n```json:metadata\n{\"files\": [\"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/LibplctagConfinementGuardTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter LibplctagConfinementGuard\", \"modelTier\": \"mechanical\", \"model\": \"haiku\", \"acceptanceCriteriaCount\": 5}\n```", + "blockedBy": [ + 2, + 3 + ] + }, + { + "id": 12, + "subject": "Task 12: `NativeAbCipTagRuntime` + factory", + "status": "completed", + "description": "**Goal:** A native `IAbCipTagRuntime` that leases the device connection from `DeviceSessionPool.Shared`, reads and writes through `LogixTagClient`, decodes the managed buffer with the same semantics as `LibplctagTagRuntime`, and adds a CIP type check.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagFactory.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipDevice.cs` (maps `AbCipTagCreateParams` \u2192 `DeviceKey` + `ConnectionOptions`; shared with Task 13)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs` (add trailing optionals `AbCipDataType? AuthoredType = null, bool UseConnectedMessaging = true` to `AbCipTagCreateParams`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs` (pass `UseConnectedMessaging: profile.SupportsConnectedMessaging`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs` (`EnsureTagRuntimeAsync` passes `AuthoredType: def.DataType`; `DeviceState`'s create-params builder passes `UseConnectedMessaging: Profile.SupportsConnectedMessaging`)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs`\n- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj` (add a `ProjectReference` to Ab.Transport and ``.; source-linking compiles the fake into this assembly, so it stays `internal`)\n\n**Acceptance Criteria:**\n- [ ] `NativeAbCipDevice.Key(p)` = `new DeviceKey(p.Gateway, p.Port > 0 ? p.Port : 44818, p.CipPath, p.LibplctagPlcAttribute)`.\n- [ ] `NativeAbCipDevice.Options(p)`:\n - `Timeout = p.Timeout`, `ConnectionSize = p.ConnectionSize`, `AllowPacking = p.AllowPacking`.\n - **`Routing`:** if `p.UseConnectedMessaging` is false, use `UnconnectedSend` when `p.CipPath` is non-empty and `Direct` when it is empty. Otherwise use `Connected`.\n - `UseConnectedMessaging` is a new trailing optional `bool UseConnectedMessaging = true` on `AbCipTagCreateParams`. It is set at every construction site from `AbCipPlcFamilyProfile.SupportsConnectedMessaging`, so the family profile stays the single authority and string-matching `\"micro800\"` is not needed (review finding 18). The sites are `AbCipDriver` `DeviceState` (around `AbCipDriver.cs:1690-1700`, where `AllowPacking`/`ConnectionSize` are already merged) and `AbCipDriverProbe`; find any others with `grep -rn \"new AbCipTagCreateParams\" src`.\n - Cross-check the result against the Task 4 \"Routing observed\" table for controllogix and micro800, and record any mismatch in the test comment.\n- [ ] **Initialization and reads:**\n - `InitializeAsync` acquires the lease from `DeviceSessionPool.Shared` and builds a per-runtime `LogixTagClient` over **the lease's shared `Scheduler` and `Channel`**. It must never construct its own `RequestScheduler`: that would defeat MSP coalescing across tags (B1). It encodes the path once, splitting off a trailing bit selector with `CipPath.SplitBitSelector`; that is stored as the runtime's implicit bit.\n - It performs **one read** to prove the tag exists. That matches libplctag, whose `InitializeAsync` errors for an unknown tag; the driver's 05/STAB-8 logic depends on create throwing `NotFound`.\n - `ReadAsync` reads `p.ElementCount` elements, stores the data, `TypeCode` and `StructHandle`, and sets status `Ok`. On `AbTransportException` it stores the exception's status **and rethrows**, matching libplctag's exception-on-failure behaviour. `GetSize()` returns the data length.\n- [ ] **Decode** is byte-identical to `LibplctagTagRuntime` for every `AbCipDataType` (Bool/SInt/USInt/Int/UInt/DInt/UDInt/LInt/ULInt/Real/LReal/String/Dt). Specifically:\n - Bool with a bit index \u21d2 bit `n` of the little-endian buffer.\n - Bool without \u21d2 `byte[0] != 0`.\n - String \u21d2 Logix STRING `u32 LEN` + `DATA`, ASCII, `LEN` clamped to the data length \u2212 4.\n - `DecodeArray` uses stride = `data.Length / count` when it divides evenly, else the static width (the same `ResolveArrayStride` rule; call `LibplctagTagRuntime.ResolveArrayStride` with `elementSize: null` to share it).\n - Bool arrays are bit-packed (bit `i` of the buffer).\n- [ ] **Type check:** after a read, if `TypeCode` is atomic and differs from the authored type's Logix code, `GetStatus()` returns `(int)AbStatus.TypeMismatch` and `Decode*` returns null. The mapping is `AbCipDataType` \u2192 `LogixTypeCode`, with `Dt \u2192 DInt`; `String`/`Structure` are exempt. Exemptions:\n - (a) A BOOL read with a bit index against a DINT host is **not** a mismatch.\n - (b) **Authored `Bool` with returned `0xD3`** (the Logix packed BOOL-array type) is **not** a mismatch (review finding 11).\n - Tests cover both exemptions, plus a positive mismatch.\n- [ ] **Encode/write:**\n - `EncodeValue` fills a managed write buffer with the same encodings as `LibplctagTagRuntime.EncodeValue`, including the same `NotSupportedException` cases.\n - `WriteAsync` writes with type = the last read `TypeCode` if known, else the authored type's code.\n - For String, it uses `Struct` + the last `StructHandle`. If none is known it performs a read first; that read happens inside `WriteAsync`, under the driver's existing per-runtime lock.\n - The STRING write payload is `u32 len` + DATA padded to the capacity learned from the last read's data length \u2212 4.\n- [ ] `Dispose` releases the lease, waiting for at most 2 s (`lease.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(2))`). It must not fire and forget: an immediate re-init would otherwise open a second session to the press while the old Forward Close is still in flight (review finding 22). It never disposes the shared scheduler; the pool does that on the last release.\n- [ ] `NativeAbCipTagFactory : IAbCipTagFactory` creates `NativeAbCipTagRuntime`.\n- [ ] Test seam: `internal static NativeAbCipTagRuntime ForTest(ICipChannel channel, AbCipTagCreateParams p)` builds a runtime over the given channel with a private `RequestScheduler(channel, p.AllowPacking)` (test-only; no pool lease). Production code always goes through the factory and `DeviceSessionPool.Shared`.\n- [ ] **Unit tests** (against `FakeCipChannel` via `ForTest`):\n - decode parity for each type, using a buffer captured from `LibplctagTagRuntime` semantics (hard-coded bytes);\n - the TypeMismatch case (REAL returned for a DInt-authored tag);\n - an unknown tag \u21d2 `InitializeAsync` throws `AbTransportException(NotFound)`;\n - a STRING write sends `Struct` + the handle learned from the prior read;\n - a 1-element array is read as an array (`IsArray`).\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter NativeAbCipTagRuntime` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 12.\n\n```json:metadata\n{\"files\": [\"ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipDevice.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagFactory.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter NativeAbCipTagRuntime\", \"modelTier\": \"frontier\", \"model\": \"opus\", \"acceptanceCriteriaCount\": 10}\n```", + "blockedBy": [ + 2, + 9 + ] + }, + { + "id": 13, + "subject": "Task 13: Native AbCip symbol enumerator + template reader", + "status": "completed", + "description": "**Goal:** Native `IAbCipTagEnumerator` and `IAbCipTemplateReader` implementations whose output buffers feed the **unchanged** decoders.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/SymbolListingEnumerator.cs` (the transport-neutral listing loop moved out of `LibplctagTagEnumerator.cs`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs` (keeps only `LibplctagListingReader` + `LibplctagTagEnumeratorFactory`)\n- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/` files referencing `LibplctagTagEnumerator` (rename to `SymbolListingEnumerator`)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagEnumerator.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTemplateReader.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagEnumeratorTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTemplateReaderTests.cs`\n\n**Acceptance Criteria:**\n- [ ] **Enumerator split.** The current `LibplctagTagEnumerator` class (its listing loop has no libplctag dependency apart from its default constructor) is renamed **`SymbolListingEnumerator`**, keeping its `Func> readListing` constructor seam. The libplctag default reader moves into `LibplctagTagEnumerator.cs` as `LibplctagListingReader`, so the Task 11 guard still allows it. `LibplctagTagEnumeratorFactory` returns `new SymbolListingEnumerator(LibplctagListingReader.ReadAsync)`.\n- [ ] **Native listing reader.** `NativeAbCipTagEnumerator` = `new SymbolListingEnumerator(NativeListingReader.ReadAsync)`, wrapped in a class for the factory. Its reader:\n - `\"@tags\"` \u21d2 pages `SymbolObjectServices.GetInstanceAttributeList(null, start)` from instance 0, appending entry bytes. While the reply is `More`, it continues from `LastInstance + 1`.\n - `\"Program:X.@tags\"` \u21d2 the same with `programName: \"X\"`.\n - It returns the concatenated bytes.\n- [ ] `NativeAbCipTemplateReader.ReadAsync(params, id)`:\n - `GetAttributes(id)` gives `defWords`, `structBytes`, `members`, `handle`.\n - It then sends `ReadTemplate(id, offset, chunk)` repeatedly, with `chunk = Math.Min(remaining, MaxRequestBytes - 16)`, where the total = `defWords * 4 - 23` (the CIP-documented Read Template length), advancing by each reply's data length until the status is 0. Status `0x06` continues.\n - It returns `TemplateObjectServices.BuildDecoderBuffer(...)`.\n- [ ] Unit tests feed the **real controller captures** through the native path. The fake channel serves, as reply data, the bytes contained in `CipTemplateObjectWireCaptureTests` (rev 34.11 press). The test asserts that `CipTemplateObjectDecoder.Decode(nativeBuffer)` equals `Decode(capturedLibplctagBuffer)`, member by member.\n - The captured libplctag buffer's header **is** the 14-byte header, so the fake replies are built from that header's fields plus the raw bytes after byte 14.\n - Do the same for `CipSymbolObjectRealControllerCaptureTests` entries: split them across two pages with a `0x06` status between, and assert the decoded tag list is identical.\n- [ ] Both factories (`NativeAbCipTagEnumeratorFactory`, `NativeAbCipTemplateReaderFactory`) exist and lease from `DeviceSessionPool.Shared` via `NativeAbCipDevice`. They send through **the lease's shared `Scheduler`**, never a private one, and dispose the lease (bounded 2 s wait, as in T12) when the enumeration or read completes.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter \"FullyQualifiedName~NativeAbCipTagEnumerator|FullyQualifiedName~NativeAbCipTemplateReader|FullyQualifiedName~Discovery\"` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 13.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagEnumerator.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTemplateReader.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/SymbolListingEnumerator.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagEnumeratorTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTemplateReaderTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter \\\"FullyQualifiedName~NativeAbCipTagEnumerator|FullyQualifiedName~NativeAbCipTemplateReader|FullyQualifiedName~Discovery\\\"\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 4}\n```", + "blockedBy": [ + 2, + 9 + ] + }, + { + "id": 14, + "subject": "Task 14: `NativeAbLegacyTagRuntime` + factory", + "status": "completed", + "description": "**Goal:** A native `IAbLegacyTagRuntime` over PCCC (Task 8) carried in Execute PCCC through a `DeviceSessionPool` lease.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagFactory.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs`\n- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj` (reference Ab.Transport + link `FakeCipChannel.cs`, as in Task 12)\n\n**Acceptance Criteria:**\n- [ ] **Connection:**\n - `DeviceKey` is built from `AbLegacyTagCreateParams` the same way as Task 12 (default port 44818).\n - **`Routing` comes from the Task 4 \"Routing observed\" table (B3), per family:**\n - Expected: `slc500`/`micrologix`/`plc5` \u21d2 **`Direct`** (libplctag sends Execute PCCC bare in SendRRData even though the driver forces `EffectiveCipPath = \"1,0\"`, `AbLegacyDriver.cs:864`); `logixpccc` \u21d2 **`Connected`**.\n - **If the capture shows otherwise, the capture wins.** Encode the choice in one `static CipRouting RoutingFor(string plcAttribute)` with a comment citing the capture file, and pin it with a test per family.\n - `ConnectionSize = 504`, `AllowPacking = false` (PCCC requests are never MSP-packed).\n - Send through the lease's shared `Scheduler`, never a private one (B1).\n- [ ] **Address handling:**\n - `AbLegacyAddress.TryParse(p.TagName)` \u21d2 `PcccAddress(FileLetter, FileNumber, WordNumber, sub)`. The sub-element comes from `ResolveSubElement`. A `BitIndex` is kept for decode only, because reads are word reads.\n - Byte counts per element: N/A/B/I/O/S = 2, F/L = 4, T/C/R = 6 when no sub-element (the whole element), 2 when a word sub-element is chosen, ST = 84.\n - Element count = `p.ElementCount`.\n- [ ] **Read and write:**\n - `ReadAsync` dispatches by `plc` attribute: `slc500`/`micrologix`/`logixpccc` \u21d2 `SlcTypedRead`; `plc5` \u21d2 `Plc5TypedRead`, or `AbTransportException(Unsupported)` when `PcccServices.Plc5Verified` is false (Task 8).\n - The TNS increments per request.\n - **Chunking (review finding 12):** a read or write whose byte count exceeds `PcccServices.MaxDataBytes` (236) is split into consecutive element ranges of at most 236 bytes (118 words, or 59 longs/floats). The element number advances per chunk and the data is concatenated in order. A failure in any chunk fails the whole operation with that chunk's status; a partial write is reported as failed, never as Good. Test a 256-word array read (three chunks: 118 + 118 + 20) and a 130-word write (two chunks).\n - On failure it stores the status and rethrows.\n - `InitializeAsync` performs one read, for the same `NotFound`-on-create reason as Task 12.\n- [ ] **Decode** is identical to `LibplctagLegacyTagRuntime`:\n - Bit with index \u21d2 bit of the LE word (`/N` up to 15, or up to 31 for L).\n - Bit without \u21d2 `(word & 1) != 0`.\n - Int/AnalogInt \u21d2 `short`\u2192`int`; Long \u21d2 `int`; Float \u21d2 `float`.\n - String \u21d2 the SLC ST layout: `u16 LEN` + 82 data bytes **with each byte pair swapped**. \u26a0\ufe0f Verify this byte order against `slc500` ST data in ab_server. If ab_server does not seed ST, test against a hand-built buffer and flag it in Task 20 docs as unverified.\n - Timer/Counter/Control element without a sub-element \u21d2 `GetInt32(0)` parity (the first 4 bytes, LE).\n - `DecodeArray` uses the offsets documented in `LibplctagLegacyTagRuntime.DecodeArray`.\n- [ ] **Encode** follows `LibplctagLegacyTagRuntime.EncodeValue` case by case, with the same `NotSupportedException` for bit-with-index. `WriteAsync` \u21d2 `SlcTypedWrite` (or PLC-5 / Unsupported).\n- [ ] **Unit tests:**\n - N7:0 read decode;\n - F8 read;\n - B3:0/5 decode;\n - an N7 array of 5;\n - T4:0.ACC sub-element addressing (bytes on the wire);\n - STS 0x20 \u21d2 `NotFound` on initialize;\n - a write of N7:0 = 42 produces the expected PCCC bytes.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter NativeAbLegacyTagRuntime` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 14.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagFactory.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter NativeAbLegacyTagRuntime\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 6}\n```", + "blockedBy": [ + 3, + 8, + 9 + ] + }, + { + "id": 15, + "subject": "Task 15: AbCip `Transport` switch (options, factory, probe, ", + "status": "completed", + "description": "**Goal:** One `Transport` option selects the native or libplctag factory set everywhere AbCip creates a transport object: the driver, Test Connect, and the CLI.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTransport.cs`\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs` (add `public AbCipTransport Transport { get; init; } = AbCipTransport.Libplctag;`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs` (DTO `string? Transport`; `ParseEnum(dto.Transport, \u2026, fallback: AbCipTransport.Libplctag)`)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTransportFactories.cs`\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:145-155` (null factories \u21d2 `AbCipTransportFactories.For(options.Transport)`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs` (use `AbCipTransportFactories.For(opts.Transport).Tags`)\n- Modify: `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/` (`--transport native|libplctag`, default `libplctag`; help text \"via libplctag\" \u2192 \"via libplctag or the native transport\")\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs`\n- Modify: `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/` (add a `--transport` parse test)\n\n**Acceptance Criteria:**\n- [ ] `public enum AbCipTransport { Libplctag, Native }`.\n- [ ] `AbCipTransportFactories.For(t)` returns `(IAbCipTagFactory Tags, IAbCipTagEnumeratorFactory Enumerators, IAbCipTemplateReaderFactory Templates)`: the native trio for `Native`, the libplctag trio for `Libplctag`.\n- [ ] Explicitly injected factories (tests) still win over the option.\n- [ ] JSON configs: `\"transport\": \"Native\"` \u21d2 Native; `\"transport\": \"native\"` \u21d2 Native (case-insensitive, as with the existing `ParseEnum`); absent \u21d2 Libplctag; `\"transport\": \"Bogus\"` \u21d2 the same failure the existing `ParseEnum` raises for a bad `PlcFamily` (do not invent a new behaviour).\n- [ ] **Reinit does NOT swap transports, by design (review finding 19).** `AbCipDriver.ReinitializeAsync` (`AbCipDriver.cs:401`) is `ShutdownAsync` + `InitializeAsync` over constructor-held factories, so a transport change in the reinit JSON is inert. Do **not** invent factory swapping:\n - Document in the option's XML doc and in `docs/drivers/AbCip.md` (T20) that changing `transport` takes effect when the driver instance is recreated, which a deploy that changes the driver config does.\n - Pin the current behaviour with a test, `Reinit_with_a_changed_transport_keeps_the_constructed_transport`, so a future change is deliberate.\n - Before writing that test, confirm how a deploy applies a changed `DriverConfig`: `grep -rn \"ReinitializeAsync\\|CreateInstance\" src/Server src/Core | grep -i driver`. If deploys recreate the instance, say so in the doc. If they call `ReinitializeAsync`, raise it as a follow-up in the commit message rather than widening this task.\n- [ ] The **probe** deserializes with its own `JsonStringEnumConverter`, so a bad `\"transport\"` reports \"Config JSON is invalid\", not the `ParseEnum` message. That is acceptable; assert that exact behaviour in the probe test rather than the driver's.\n- [ ] The probe with `Transport: Native` constructs a `NativeAbCipTagRuntime`. Prove it with a test that uses an unreachable `127.0.0.1:1` endpoint and asserts the failure message comes from the native path (\"TCP connect \u2026 failed\"), not libplctag's.\n- [ ] The CLI `--transport native` reaches `AbCipDriverOptions.Transport`.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter AbCipTransportSwitch && dotnet test tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 15.\n\n```json:metadata\n{\"files\": [\"src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTransport.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:145-155\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTransportFactories.cs\", \"tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter AbCipTransportSwitch && dotnet test tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 8}\n```", + "blockedBy": [ + 12, + 13 + ] + }, + { + "id": 16, + "subject": "Task 16: AbLegacy `Transport` switch (options, factory, prob", + "status": "completed", + "description": "**Goal:** Do the same as Task 15 for AbLegacy.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTransport.cs` (`public enum AbLegacyTransport { Libplctag, Native }`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs` (`Transport` property, default `Libplctag`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs` (DTO + parse)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs:65-71` (`_tagFactory = tagFactory ?? (options.Transport == AbLegacyTransport.Native ? new NativeAbLegacyTagFactory() : new LibplctagLegacyTagFactory());`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs`\n- Modify: `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/` (`--transport`)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs`\n- Modify: `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/`\n\n**Acceptance Criteria:** the same five criteria as Task 15, applied to AbLegacy types:\n- [ ] `\"transport\": \"Native\"` and `\"native\"` select the native factory, absent selects libplctag, and a bad value fails the way an existing bad enum field does.\n- [ ] An injected factory wins over the option.\n- [ ] The probe with Native uses the native path; prove it with the unreachable-endpoint message test.\n- [ ] The CLI `--transport native` reaches the options.\n- [ ] A reinit with a changed transport keeps the constructed transport (`AbLegacyDriver` reinit has the same Shutdown+Initialize shape), pinned by the same kind of test and documentation as Task 15.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter AbLegacyTransportSwitch && dotnet test tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 16.\n\n```json:metadata\n{\"files\": [\"src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTransport.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs:65-71\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs\", \"tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter AbLegacyTransportSwitch && dotnet test tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 6}\n```", + "blockedBy": [ + 14 + ] + }, + { + "id": 17, + "subject": "Task 17: AbCip + AbLegacy integration suites on both transpo", + "status": "completed", + "description": "**Goal:** Every existing `ab_server` integration test runs once per transport, and the native run is green wherever the libplctag run is green, against every compose profile.\n\n**Files:**\n- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs` and every other test in that project that constructs `AbCipDriverOptions` (`grep -rln \"new AbCipDriverOptions\" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests`)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs`\n- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs` and siblings\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs`\n- Modify: `docs/plans/2026-10-07-native-ab-transport-gate-results.md` (created empty by Task 1; this task fills only its `## ab_server suites (T17)` section)\n\n**Acceptance Criteria:**\n- [ ] `TransportMatrix.Profiles` yields `(profile, transport)` for every profile \u00d7 both transports. The theories switch from `MemberData(nameof(Profiles))` to `MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))`, and options get `Transport = transport`.\n- [ ] **The AbLegacy matrix is its own class** in the AbLegacy integration project, using `AbLegacyTransport`, and it **preserves the existing `AB_LEGACY_COMPOSE_PROFILE` filter** that `AbLegacyReadSmokeTests.Profiles` applies today (`AbLegacyReadSmokeTests.cs:26-35`): it must filter `KnownProfiles.All` by that env var before crossing with transports. The AbCip snippet below does not apply to AbLegacy verbatim (review finding 20).\n- [ ] Run against `10.100.0.35`, one compose profile at a time:\n - AbCip: `controllogix`, `compactlogix`, `micro800`, `guardlogix`.\n - AbLegacy: `slc500`, `micrologix`, `plc5`.\n - **For each profile, the set of passing tests under `Native` \u2287 the set under `Libplctag`.**\n- [ ] Any native-only failure is fixed in the transport task's code (reopen that task's files) and is not skipped. The only allowed exception is the PLC-5 `Unsupported` path from Task 8, marked `Skip = \"PLC-5 framing unverified \u2014 see Task 8\"` **only if** Task 8 set `Plc5Verified = false`.\n- [ ] The results doc records, under its `## ab_server suites (T17)` heading, per profile \u00d7 transport, the pass/fail/skip counts plus the `ab_server` image tag and the commit.\n\n**Verify:** for each profile P (AbCip stack directory as established in Task 4's `Captures/README.md`; AbLegacy at `~/otopcua-ablegacy` with `AB_LEGACY_COMPOSE_PROFILE=P`): `ssh dohertj2@10.100.0.35 'cd && docker compose --profile P up -d'` then `AB_SERVER_ENDPOINT=10.100.0.35:44818 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --logger \"trx;LogFileName=P.trx\"` \u2192 the Native pass set \u2287 the Libplctag pass set (compare the two trx files with `grep -o 'testName=\"[^\"]*\" .*outcome=\"[^\"]*\"'`).\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 17.\n\n```json:metadata\n{\"files\": [\"docs/plans/2026-10-07-native-ab-transport-gate-results.md\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs\"], \"verifyCommand\": \"AB_SERVER_ENDPOINT=10.100.0.35:44818 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --logger \\\"trx;LogFileName=P.trx\\\" && grep -o 'testName=\\\"[^\\\"]*\\\" .*outcome=\\\"[^\\\"]*\\\"'\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 5}\n```", + "blockedBy": [ + 15, + 16 + ] + }, + { + "id": 18, + "subject": "Task 18: Differential harness (libplctag vs native, same tar", + "status": "completed", + "description": "**Goal:** A `LiveIntegration` test that reads the same tags through both transports against one endpoint and asserts equal raw buffers and equal decoded values. It skips cleanly when the endpoint env var is absent.\n\n**Files:**\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialTarget.cs`\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs` and `NativeAbCipTagRuntime.cs` (add `internal ReadOnlyMemory RawBuffer`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj` (`InternalsVisibleTo` for `ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests`)\n\n**Acceptance Criteria:**\n- [ ] **Env gating:**\n - `AB_DIFF_ENDPOINT` (`host:port`), `AB_DIFF_PATH` (default `1,0`), `AB_DIFF_FAMILY` (default `CompactLogix`).\n - `AB_DIFF_TAGS`: an optional comma list. Absent \u21d2 every atomic scalar, the first 20 arrays and the first 20 UDT members from browse.\n - `AB_DIFF_MAX_TAGS` (default 200).\n - Absent `AB_DIFF_ENDPOINT` \u21d2 every test reports **skipped**, with the reason naming the variable.\n- [ ] **The suite is read-only by construction.** It never calls `WriteAsync` or `EncodeValue`. A guard test reflects over `TransportDifferentialTests` and fails if any method body references `WriteAsync` (scan its source file).\n- [ ] **Browse parity:** `@tags` plus every program listing through both enumerators decode to identical `AbCipDiscoveredTag` sets (order-insensitive). Every `TemplateInstanceId` seen decodes to an identical `AbCipUdtShape` through both template readers.\n- [ ] **Read parity:** for each selected tag, under both transports:\n - Run `InitializeAsync` + `ReadAsync`.\n - Assert `GetStatus()` is equal, **except** that native `TypeMismatch` where libplctag says `Ok` is reported as a distinct *expected divergence* (spec \u00a75), listed in the output and not failed.\n - Assert the raw buffers are equal. To expose the raw bytes, add `internal ReadOnlyMemory RawBuffer` to both runtimes: libplctag's via `_tag.GetBuffer()`, native's via its stored data. Add `InternalsVisibleTo` for the integration test assembly in the AbCip csproj.\n - Assert the decoded values are equal.\n- [ ] **Connection discipline (review finding 9):**\n - Before any native work, the harness acquires one `DeviceSessionPool.Shared.Acquire(key, options)` **anchor lease** for the target, and holds it until the run ends. Per-tag native runtimes therefore share that one connection: the pool never reaches refcount 0, so it never sends Forward Close and re-opens between tags.\n - libplctag runtimes and native runtimes are never alive at the same time. All libplctag reads run first, and their handles are disposed. Then all native reads run. So the press sees at most two sessions over the run, never concurrently.\n - With `AB_DIFF_COUNT_OPENS=1`, the native side asserts it made exactly **one** Forward Open over the whole run, using `CipConnection.ForwardOpenCount` from Task 9 (reset it at the start of the run).\n- [ ] Output: a markdown table (tag, type, libplctag status, native status, equal?) written to `TestContext.Current.TestOutputHelper`.\n- [ ] Results are appended under the gate-results doc's `## Differential vs ab_server (T18)` heading only.\n- [ ] Run against `ab_server` (`controllogix` profile) with `AB_DIFF_ENDPOINT=10.100.0.35:44818` \u21d2 zero unexpected divergences. Append the results to the gate-results doc.\n- [ ] **Ordering:** blocked by Task 17, so any transport fixes found by the ab_server suites land before the differential run.\n\n**Verify:** `AB_DIFF_ENDPOINT=10.100.0.35:44818 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --filter \"FullyQualifiedName~Differential\"` \u2192 pass with 0 unexpected divergences; without the env var \u2192 all skipped.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 18.\n\n```json:metadata\n{\"files\": [\"ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialTarget.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs\"], \"verifyCommand\": \"AB_DIFF_ENDPOINT=10.100.0.35:44818 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --filter \\\"FullyQualifiedName~Differential\\\"\", \"modelTier\": \"frontier\", \"model\": \"opus\", \"acceptanceCriteriaCount\": 9}\n```", + "blockedBy": [ + 17 + ] + }, + { + "id": 19, + "subject": "Task 19: AdminUI `Transport` dropdown (AbCip + AbLegacy) + d", + "status": "completed", + "description": "**Goal:** An operator can pick the transport per driver in the `/raw` driver-config modal, the value round-trips as a string, and a live docker-dev run proves it reaches the driver.\n\n**Files:**\n- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor` (`FormModel.Transport`, `ToOptions`/`FromOptions`, an `InputSelect` in the advanced section)\n- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor` (same)\n- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs` (add Transport round-trip cases)\n- Create or modify: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs` (exists \u21d2 add cases; absent \u21d2 create, modelled on the AbLegacy file)\n\n**Acceptance Criteria:**\n- [ ] The dropdown has options \"libplctag (default)\" \u21d2 `Libplctag` and \"Native (managed)\" \u21d2 `Native`, bound with `@bind-Value:after=\"EmitAsync\"` the way the other fields are.\n- [ ] The serialized JSON contains `\"transport\":\"Native\"` (a string, not a number). It goes through the existing `_jsonOpts` with `JsonStringEnumConverter` (`AbCipDriverForm.razor:108-113`); verify the AbLegacy form has the same converter and add it if missing.\n- [ ] Loading a config without `transport` shows \"libplctag (default)\" and re-emits **without** a `transport` key, or with `\"Libplctag\"`; either is fine, but pick one and test it.\n- [ ] **Live `/run` on docker-dev** (login is disabled; drive it yourself with the Chrome tools against `http://localhost:9200`):\n 1. Bring up the AbCip `controllogix` fixture on `10.100.0.35`.\n 2. In `/raw`, create or edit an AbCip driver with a device `ab://10.100.0.35:44818/1,0`, set Transport = Native, and add the raw tag `TestDINT`.\n 3. Deploy.\n 4. Read the UNS/raw node with the Client CLI (`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n \"ns=;s=\"`) and get `Good`.\n 5. Confirm from the central node's logs that the native runtime served it. Add one `LogDebug(\"AbCip transport={Transport}\")` at driver init if no log line distinguishes the two.\n 6. Flip back to libplctag, redeploy, and read `Good` again.\n 7. Record the output under the gate-results doc's `## AdminUI /run (T19)` heading only.\n- [ ] **Inverted-gate check** (memory: \"an unchanged observable proves nothing\"): with Transport = Native, point the device at a port where no `ab_server` is listening. The read must go **Bad** and the log must show the *native* error text. That proves the switch changes behaviour and isn't inert.\n\n**Verify:** AdminUI unit tests pass, plus the docker-dev `/run` evidence (CLI output for both transports and the inverted case) is pasted into `docs/plans/2026-10-07-native-ab-transport-gate-results.md`.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 19.\n\n```json:metadata\n{\"files\": [\"src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor\", \"src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor\", \"tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs\", \"tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs\"], \"verifyCommand\": \"\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 5}\n```", + "blockedBy": [ + 17 + ] + }, + { + "id": 20, + "subject": "Task 20: Documentation", + "status": "completed", + "description": "**Goal:** Document the native transport, the switch, the known gaps and the client-visible type-check change, in the repo and in the umbrella history.\n\n**Files:**\n- Modify: `docs/drivers/AbCip.md` (sections: \"Transport (`Transport` option)\", \"Native transport behaviour\" covering connection sharing, MSP packing, the type check and press-safety limits, and \"Known gaps\" covering simulator-only writes, PLC-5/Micro800/routed paths, and libplctag deletion blocked on a bench controller)\n- Modify: `docs/drivers/AbLegacy.md` (Transport section, PCCC simulator-only gap, PLC-5 status from Task 8, ST byte-order status from Task 14)\n- Modify: `docs/drivers/AbServer-Test-Fixture.md` and `docs/drivers/AbLegacy-Test-Fixture.md` (suites now run per transport; the capture recipe `scripts/ab-capture/capture.sh`)\n- Modify: `CLAUDE.md` (one short paragraph under a new heading \"AB drivers: native transport (`Transport`)\" with the switch, the confinement guard, and a pointer to the docs; keep it short, under 15 lines)\n- Modify: `../scadaproj/.claude/skills/sister-project-history/otopcua.md` (append a dated entry), on scadaproj's `main` per the CLAUDE.md procedure\n\n**Acceptance Criteria:**\n- [ ] `docs/drivers/AbCip.md` states explicitly:\n - The native path reports `BadTypeMismatch` for a same-width mis-authored type that libplctag reads as Good garbage, with an example (DINT authored, REAL returned).\n - That is the fix working, and it is called out as client-visible.\n- [ ] Every config key added (`transport`) is documented with its values, its default and string serialization.\n- [ ] The scadaproj history entry is committed on `main` and pushed. The OtOpcUa index **row** in `../scadaproj/CLAUDE.md` is **not** touched; the summary does not change, since the driver set and wire contract are the same.\n- [ ] The scadaproj branch is restored afterwards.\n\n**Verify:** `grep -n \"Transport\" docs/drivers/AbCip.md docs/drivers/AbLegacy.md CLAUDE.md` shows the new sections; `cd ../scadaproj && git log origin/main -1 --format=%s` shows the history commit.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 20.\n\n```json:metadata\n{\"files\": [\"../scadaproj/.claude/skills/sister-project-history/otopcua.md\", \"CLAUDE.md\", \"docs/drivers/AbCip.md\", \"docs/drivers/AbLegacy-Test-Fixture.md\", \"docs/drivers/AbLegacy.md\", \"docs/drivers/AbServer-Test-Fixture.md\", \"scripts/ab-capture/capture.sh\"], \"verifyCommand\": \"grep -n \\\"Transport\\\" docs/drivers/AbCip.md docs/drivers/AbLegacy.md CLAUDE.md\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 4}\n```", + "blockedBy": [ + 19 + ] + }, + { + "id": 21, + "subject": "Task 21: Real-press read-only gate (CR CompactLogix)", + "status": "completed", + "description": "**Goal:** Run the Task 18 differential harness and a latency comparison against one production CompactLogix 5069-L320ERS2 press at CR, **read-only**, and record the evidence that decides the AbCip default flip.\n\n> **USER-ORDERED GATE \u2014 NON-SKIPPABLE.** This task was requested by the user in the current conversation. It MUST NOT be closed by walking around it, by declaring it \"verified inline\", or by substituting a cheaper check. Close only after every item in `acceptanceCriteria` has been re-validated independently, with output captured.\n\n**Files:**\n- Modify: `docs/plans/2026-10-07-native-ab-transport-gate-results.md` (append the press results)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs`\n\n**Acceptance Criteria:**\n- [ ] **Preconditions (human):** the VPN to CR is up, the user has OK'd touching the press at this time, and the target is **one** press. The default is Z-35162 at `10.209.70.160:44818`, path `1,0`; any other press must be named by the user.\n- [ ] **Run the differential:**\n - `AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_DIFF_FAMILY=CompactLogix AB_DIFF_COUNT_OPENS=1 dotnet test \u2026--filter \"FullyQualifiedName~Differential\"` passes.\n - Browse parity holds: the same discovered tag set (the 2026-10-07 run saw ~23,412 variables across 80 programs) and identical UDT shapes.\n - Read parity holds over `AB_DIFF_MAX_TAGS=200`, with zero unexpected divergences. Every `TypeMismatch` expected-divergence is listed by tag name.\n - Native made exactly **1** Forward Open over the run. That is achievable only because T18's harness holds an anchor lease for the whole run; if the count is higher, the harness is broken, not the press.\n- [ ] **Latency comparison** (`PollLatencyComparisonTests`, env-gated by the same variables):\n - Read a fixed set of 100 atomic tags 30 times per transport, sequentially; transports are never concurrent. The native side runs under an anchor lease, as in T18.\n - Native passes issue all 100 reads **concurrently** (`Task.WhenAll`), so the shared scheduler can MSP-pack them. libplctag passes do the same; libplctag packs internally.\n - Record the CIP round-trips per pass (native: `CipConnection.RequestsSent` from Task 9, delta per pass; libplctag: report n/a) and the p50/p95 wall time per pass.\n - Pass criterion: native p95 \u2264 libplctag p95 \u00d7 1.10.\n- [ ] **No writes:**\n - Evidence: the Task 18 source guard passes.\n - `grep -c WriteAsync` over both differential test files returns 0.\n- [ ] **Recorded results:** under the `## Real press (T21)` and `## Latency (T21)` headings, the results doc carries the raw `dotnet test` output, the divergence table, the latency table, the Forward Open count, the date/time (UTC) of the run and the press ID.\n- [ ] **Failure handling:** if any criterion fails, the task stays open. Fix the code (with a pinning test), re-run the ab_server suites (Task 17) and the press run. **Do not** relax the criterion.\n\n**Verify:** `AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_DIFF_FAMILY=CompactLogix AB_DIFF_COUNT_OPENS=1 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --filter \"FullyQualifiedName~Differential\" --logger \"console;verbosity=detailed\"` \u2192 passed, 0 unexpected divergences, `ForwardOpenCount=1`, and native p95 \u2264 1.10 \u00d7 libplctag p95.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 21.\n\n```json:metadata\n{\"files\": [\"docs/plans/2026-10-07-native-ab-transport-gate-results.md\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs\"], \"verifyCommand\": \"AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_DIFF_FAMILY=CompactLogix AB_DIFF_COUNT_OPENS=1 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --filter \\\"FullyQualifiedName~Differential\\\" --logger \\\"console;verbosity=detailed\\\"\", \"modelTier\": \"frontier\", \"model\": \"opus\", \"userGate\": true, \"tags\": [\"user-gate\"], \"gateScope\": \"task\", \"failurePolicy\": \"keep-open-and-fix\", \"requireEvidenceTokens\": [[\"libplctag\", \"baseline\"], [\"native\"], [\"ForwardOpenCount=1\", \"ForwardOpenCount: 1\"]], \"acceptanceCriteriaCount\": 6}\n```", + "blockedBy": [ + 18 + ] + }, + { + "id": 22, + "subject": "Task 22: Flip the defaults to Native + release note", + "status": "completed", + "description": "**Goal:** Make `Native` the default transport for both drivers (spec \u00a78 P5), keeping `Libplctag` selectable, and write the client-visible release note.\n\n**Files:**\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs` (default `AbCipTransport.Native`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs` (`fallback: AbCipTransport.Native`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs` (same)\n- Modify: both AdminUI forms (relabel the options as \"Native (default)\" / \"libplctag (fallback)\")\n- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs`, `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs` (the absent \u21d2 Native expectation)\n- Modify: the CLIs' `--transport` default (`native`)\n- Modify: `docs/drivers/AbCip.md`, `docs/drivers/AbLegacy.md` (the default, and a \"Release note\" subsection)\n\n**Acceptance Criteria:**\n- [ ] Blocked by Task 21 passing **and** Task 17 green for all AbLegacy profiles.\n- [ ] A config with no `transport` key runs Native in both drivers; prove it with the switch tests.\n- [ ] Existing unit tests that construct `AbCipDriver` with **no** injected factory and expect libplctag behaviour are updated to set `Transport = Libplctag` explicitly. Find them with `grep -rn \"new AbCipDriver(\" tests | grep -v tagFactory`.\n- [ ] The release note says:\n - The native transport is the default.\n - It reports `BadTypeMismatch` for mis-authored same-width types.\n - Set `\"transport\": \"Libplctag\"` to fall back.\n - AbCip writes and all of AbLegacy are simulator-proven only.\n- [ ] The full `dotnet test ZB.MOM.WW.OtOpcUa.slnx` passes, except suites that need fixtures that are not up; those are listed as skipped.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests` \u2192 all pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 22.\n\n```json:metadata\n{\"files\": [\"docs/drivers/AbCip.md\", \"docs/drivers/AbLegacy.md\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests\", \"modelTier\": \"mechanical\", \"model\": \"haiku\", \"acceptanceCriteriaCount\": 5}\n```", + "blockedBy": [ + 21, + 17, + 19, + 20 + ] + } + ], + "lastUpdated": "2026-10-07T19:16:30.130246+00:00" +} \ No newline at end of file diff --git a/scripts/ab-capture/capture.sh b/scripts/ab-capture/capture.sh new file mode 100755 index 00000000..31e185d5 --- /dev/null +++ b/scripts/ab-capture/capture.sh @@ -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: +# +# > server EIP frame, uppercase hex, no spaces> +# < 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= scripts/ab-capture/capture.sh run -- +# scripts/ab-capture/capture.sh tohex +# scripts/ab-capture/capture.sh client +# +# `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 diff --git a/scripts/ab-capture/client/Program.cs b/scripts/ab-capture/client/Program.cs new file mode 100644 index 00000000..c8a74539 --- /dev/null +++ b/scripts/ab-capture/client/Program.cs @@ -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 [count] +// ab-capture-client write +// +// a libplctag.PlcType name: ControlLogix, Micro800, Slc500, MicroLogix, Plc5, LogixPccc +// 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 elements; +// 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 <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(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 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 { 0x91, (byte)name.Length }; + sym.AddRange(name); + if (sym.Count % 2 == 1) sym.Add(0); + var write = new List { 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 { 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 { 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 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]; +} diff --git a/scripts/ab-capture/client/ab-capture-client.csproj b/scripts/ab-capture/client/ab-capture-client.csproj new file mode 100644 index 00000000..8d5476ad --- /dev/null +++ b/scripts/ab-capture/client/ab-capture-client.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + enable + enable + false + false + + + + + diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCliTransport.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCliTransport.cs new file mode 100644 index 00000000..3a66f770 --- /dev/null +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCliTransport.cs @@ -0,0 +1,32 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli; + +/// +/// The CLI's --transport choice. leaves +/// null so the driver resolves it through +/// exactly as an absent transport key in a deployed config does. +/// +public enum AbCipCliTransport +{ + /// Use the driver's default (resolved by ). + Default, + + /// The managed native transport. + Native, + + /// The libplctag fallback transport. + Libplctag, +} + +/// Maps a onto the driver option. +public static class AbCipCliTransportExtensions +{ + /// Returns the configured transport: null for . + /// The CLI choice. + /// The value for . + public static AbCipTransport? ToConfigured(this AbCipCliTransport choice) => choice switch + { + AbCipCliTransport.Native => AbCipTransport.Native, + AbCipCliTransport.Libplctag => AbCipTransport.Libplctag, + _ => null, + }; +} diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs index c69d04b1..e0f61ed0 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/AbCipCommandBase.cs @@ -26,6 +26,11 @@ public abstract class AbCipCommandBase : DriverCommandBase "ControlLogix / CompactLogix / Micro800 / GuardLogix (default ControlLogix).")] public AbCipPlcFamily Family { get; init; } = AbCipPlcFamily.ControlLogix; + /// Gets the transport stack used to reach the PLC. + [CommandOption("transport", Description = + "Transport stack: default (= native), native (managed EtherNet/IP + CIP) or libplctag (fallback).")] + public AbCipCliTransport Transport { get; init; } = AbCipCliTransport.Default; + /// Gets the per-operation timeout in milliseconds. [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, diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Program.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Program.cs index 2f561e1d..df586a3d 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Program.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/Program.cs @@ -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); diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCliTransport.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCliTransport.cs new file mode 100644 index 00000000..43aec121 --- /dev/null +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCliTransport.cs @@ -0,0 +1,32 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli; + +/// +/// The CLI's --transport choice. leaves +/// null so the driver resolves it through +/// exactly as an absent transport key in a deployed config does. +/// +public enum AbLegacyCliTransport +{ + /// Use the driver's default (resolved by ). + Default, + + /// The managed native transport. + Native, + + /// The libplctag fallback transport. + Libplctag, +} + +/// Maps a onto the driver option. +public static class AbLegacyCliTransportExtensions +{ + /// Returns the configured transport: null for . + /// The CLI choice. + /// The value for . + public static AbLegacyTransport? ToConfigured(this AbLegacyCliTransport choice) => choice switch + { + AbLegacyCliTransport.Native => AbLegacyTransport.Native, + AbLegacyCliTransport.Libplctag => AbLegacyTransport.Libplctag, + _ => null, + }; +} diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs index 49b4dfcd..e96b1a75 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs @@ -25,6 +25,13 @@ public abstract class AbLegacyCommandBase : DriverCommandBase "Slc500 / MicroLogix / Plc5 / LogixPccc (default Slc500).")] public AbLegacyPlcFamily PlcType { get; init; } = AbLegacyPlcFamily.Slc500; + /// Gets the transport stack used to reach the PLC. + [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; + /// Gets the per-operation timeout in milliseconds. [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 }, }; diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Program.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Program.cs index 7db0c5fb..be23d9e9 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Program.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Program.cs @@ -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); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatus.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatus.cs new file mode 100644 index 00000000..73006ab7 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatus.cs @@ -0,0 +1,22 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +/// Transport-neutral status shared by the libplctag and native AB transports. +/// Zero is success; every error is negative so existing status != 0 checks stay correct. +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, +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs new file mode 100644 index 00000000..cec4866e --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs @@ -0,0 +1,31 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +public static class AbStatusCodes +{ + public static AbStatus FromCip(byte general, ReadOnlySpan 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, + }; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbTransportException.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbTransportException.cs new file mode 100644 index 00000000..baa6694e --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbTransportException.cs @@ -0,0 +1,9 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +/// Thrown by both AB transports. drives OPC UA status mapping and the +/// driver's "controller answered, don't throttle the device" classification (05/STAB-8). +public sealed class AbTransportException(AbStatus status, string message, Exception? inner = null) + : Exception(message, inner) +{ + public AbStatus Status { get; } = status; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs new file mode 100644 index 00000000..5004d731 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs @@ -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; + +/// +/// One device's CIP channel: one EtherNet/IP session and, when , one +/// class-3 connection. Opens lazily on first use, reconnects (through the ) +/// after the session faults, and keeps an idle connection alive. +/// +/// +/// Reconnect. Only a faulted session (timeout, I/O error, malformed frame) is reopened, and +/// only by the next send; the failing call throws its own exception. Caller cancellation never +/// faults the session (B2), so it never causes a reconnect. +/// Bounded. Every 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 +/// MinForwardOpenInterval + 4 × Timeout (connect, RegisterSession, Forward Open, fallback Forward +/// Open), and the exchange by 2 × Timeout (waiting behind one in-flight exchange, then its own). +/// Exceeding either surfaces as (). +/// Connection identity. 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. +/// +public sealed class CipConnection : ICipChannel, IAsyncDisposable +{ + /// Forward Open frames sent by every connection in the process (fallback included). Gate diagnostics. + internal static int ForwardOpenCount; + + /// Forward Close requests put on the wire by every connection in the process. Gate diagnostics. + internal static int ForwardClosesSent; + + /// Forward Close requests that got a reply within their budget. Gate diagnostics. + internal static int ForwardClosesAcked; + + /// Channel sends by every connection in the process (keep-alives included). Gate diagnostics. + 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; + + /// Round-trip statistics of the exchanges completed since the last . + internal readonly record struct ExchangeStats(long Count, double MinMs, double MaxMs, double MeanMs); + + /// Clears the process-wide round-trip statistics. Gate diagnostics. + 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); + } + + /// Snapshot of the process-wide round-trip statistics. Gate diagnostics. + 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) { } + } + + /// 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. + internal static int OpenAttempts; + + /// + /// Process-wide cap on ; -1 (the default) is unlimited. An attempt beyond + /// the cap fails with 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. + /// + internal static int OpenAttemptBudget = -1; + + /// Get Attribute Single, Identity (class 1) instance 1 attribute 1 (Vendor ID). + internal static readonly byte[] KeepAliveRequest = [0x0E, 0x03, 0x20, 0x01, 0x24, 0x01, 0x30, 0x01]; + + /// Largest unconnected message a target is guaranteed to accept. + 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? _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); + + /// The device; is the route (ignored for Direct). + /// Routing, sizes, timeouts and keep-alive settings. + /// The device's process-lifetime Forward Open throttle. + /// Clock for the throttle, deadlines and keep-alive. + /// Completes when the previous connection for the same key has + /// finished closing, so two connections to one device never overlap. + 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); + } + + /// The options this connection was built with. + internal ConnectionOptions Options => _o; + + /// Test hook: keep-alives started (counted synchronously when the timer decides to send). + 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> SendAsync(ReadOnlyMemory cipRequest, CancellationToken cancellationToken) + { + ThrowIfDisposed(); + var open = await EnsureOpenAsync(cancellationToken).ConfigureAwait(false); + return await ExchangeAsync(open, cipRequest, cancellationToken).ConfigureAwait(false); + } + + /// 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). + /// 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 wait, 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 Forward Closes it. + private Task EnsureOpenAsync(CancellationToken ct) + { + if (Volatile.Read(ref _open) is { Client.IsConnected: true } live) return Task.FromResult(live); + + Task pending; + TaskCompletionSource? started = null; + lock (_sync) + { + ThrowIfDisposed(); + if (_open is { Client.IsConnected: true } current) return Task.FromResult(current); + if (_pendingOpen is null) + { + started = new TaskCompletionSource(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); + } + + /// The shared open. Bounded by (ruling R9-3); its outcome, success or + /// failure, goes to every caller waiting on it. + private async Task RunOpenAsync(TaskCompletionSource 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 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 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; + } + + /// One request over an established pair, bounded by . + private async Task> ExchangeAsync(Open open, ReadOnlyMemory 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."); + } + } + + /// One-shot timer, re-armed after every tick so the longest idle gap is one + /// (half the connection expiry), not two. + 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."); + } + + /// Stops the keep-alive, sends Forward Close (Connected only) and closes the session, whose + /// own dispose sends UnRegisterSession. Later sends throw . + 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? 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); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs new file mode 100644 index 00000000..058176aa --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs @@ -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 +{ + /// Route path from "port,link[,port,link…]". Links that are not a byte value are IP + /// addresses, encoded as extended link-address port segments. + 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(); + } + + /// Splits a trailing purely numeric member ("Tag.5") into the tag name and bit index. + 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(); + } + + /// Split on '.' outside brackets, keeping "Program:X" whole. + private static IEnumerable 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(); + } + + /// Prefixes the path with its size in 16-bit words. + public static byte[] WithPathSize(byte[] segments) + { + var w = new CipWriter(); + w.U8((byte)(segments.Length / 2)).Bytes(segments); + return w.ToArray(); + } + + /// Message Router (class 2, instance 1). + public static readonly byte[] MessageRouter = [0x20, 0x02, 0x24, 0x01]; + + /// Connection Manager (class 6, instance 1). + public static readonly byte[] ConnectionManager = [0x20, 0x06, 0x24, 0x01]; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipReply.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipReply.cs new file mode 100644 index 00000000..b43f9059 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipReply.cs @@ -0,0 +1,22 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; + +/// A parsed CIP message-router reply: service (request code, 0x80 bit stripped), general +/// status, extended status words, and the reply data. +public sealed record CipReply(byte Service, byte GeneralStatus, ushort[] ExtendedStatus, ReadOnlyMemory Data) +{ + public AbStatus Status => AbStatusCodes.FromCip(GeneralStatus, ExtendedStatus); + + public static CipReply Parse(ReadOnlyMemory 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); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs new file mode 100644 index 00000000..50dd65b0 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs @@ -0,0 +1,75 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; + +/// Parameters for a (Large) Forward Open. is the maximum CIP +/// connection payload in bytes (e.g. 4002 for Large, 504 for regular). +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); + +/// Connection Manager (class 6) request builders and reply parsers. +public static class ConnectionManagerServices +{ + /// Priority/time-tick byte. + public const byte PriorityTick = 0x0A; + + /// Timeout ticks. The brief proposed 0x0E; every libplctag capture (Forward Open, Forward + /// Close, Unconnected Send) carries 0x05, and the capture wins. + public const byte TimeoutTicks = 0x05; + + public static byte[] UnconnectedSend(ReadOnlySpan embedded, ReadOnlySpan 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()); + } + + /// 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). + 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 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(); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionOptions.cs new file mode 100644 index 00000000..a8f95831 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionOptions.cs @@ -0,0 +1,100 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; + +/// How CIP requests reach the target's message router (review finding B3; the captures decide +/// each family's mode). +public enum CipRouting +{ + /// Forward Open a class-3 connection, then SendUnitData. Logix and Micro800 (empty route). + Connected, + + /// SendRRData wrapping each request in Unconnected Send (0x52) carrying the route. + UnconnectedSend, + + /// SendRRData carrying the request bare; any configured CIP path is ignored (PCCC families). + Direct, +} + +/// Per-device connection settings. Validated on assignment so an unusable value fails at the +/// configuration seam rather than on the wire. +public sealed record ConnectionOptions +{ + /// Press safety floor: no setting may let Forward Opens to one device start closer together. + public static readonly TimeSpan MinimumForwardOpenInterval = TimeSpan.FromSeconds(1); + + /// Originator serial shared by every connection this process opens. + 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; + + /// Pack queued requests into Multiple Service Packets (the scheduler's switch). + public bool AllowPacking { get; init; } + + /// 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. + 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."); + } + + /// Requested connection size in bytes. Above 511 the open is a Large Forward Open (0x5B). + 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."); + } + + /// Minimum time between Forward Open attempt starts for one device (≥ 1 s, press safety). + 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)."); + } + + /// Requested packet interval for both directions, in microseconds. + public uint RpiMicros + { + get => _rpiMicros; + init => _rpiMicros = value > 0 ? value : throw new ArgumentOutOfRangeException(nameof(RpiMicros), value, "RPI must be positive."); + } + + /// Encoded connection timeout multiplier: the connection expires after RPI × (4 << value). + /// The default 1 means ×8, so 2 s × 8 = 16 s. + public byte TimeoutMultiplier + { + get => _timeoutMultiplier; + init => _timeoutMultiplier = value <= 7 ? value : throw new ArgumentOutOfRangeException(nameof(TimeoutMultiplier), value, "Multiplier must be 0..7."); + } + + /// How long an idle connected session waits before the target would drop it. + public TimeSpan ConnectionExpiry => TimeSpan.FromTicks(RpiMicros * 10L * (4L << TimeoutMultiplier)); + + /// Idle time after which a keep-alive is sent (Connected only). Defaults to half the + /// connection expiry: RPI × 8 / 2 = 8 s. + 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; + + /// Random per process; together with the vendor id and connection serial it names a connection. + public uint OriginatorSerial { get; init; } = ProcessOriginatorSerial; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/DeviceKey.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/DeviceKey.cs new file mode 100644 index 00000000..c8331d21 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/DeviceKey.cs @@ -0,0 +1,37 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; + +/// +/// Identity of one physical CIP connection: exactly one (and one Forward Open +/// throttle) exists per key (press safety). and compare +/// case-insensitively; compares exactly; is canonicalised (each +/// comma-separated part trimmed, so "1,0" and " 1, 0 " are the same device) and then +/// compares exactly. Whitespace INSIDE a part is kept, so a typo such as "1,2 3" still fails loudly in +/// instead of silently becoming slot 23. +/// +public sealed record DeviceKey(string Host, int Port, string CipPath, string PlcAttribute) +{ + private readonly string _cipPath = Canonicalise(CipPath); + + /// The route with each part trimmed. Canonicalised on construction and on with. + 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)); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ForwardOpenThrottle.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ForwardOpenThrottle.cs new file mode 100644 index 00000000..b315aa06 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ForwardOpenThrottle.cs @@ -0,0 +1,61 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; + +/// 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. +/// One instance per , kept by 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 start, whatever +/// that attempt's outcome. +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 + + /// Internal: the only legitimate owner is , which keeps exactly + /// one per device. A second throttle for the same device would halve the guaranteed interval. + 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)); + } + + /// Run one open attempt once the throttle allows it. bounds only the wait + /// BEFORE the attempt (queued behind an in-flight attempt, or the interval): when it fires there, this + /// throws (, "Forward Open + /// throttled"). is also passed to , which may ignore it: + /// does, running the attempt under its own open deadline so that an accepted + /// Forward Open is never abandoned without a Forward Close. + public async Task RunAsync(Func> 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}."); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ICipChannel.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ICipChannel.cs new file mode 100644 index 00000000..42e0b9e1 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ICipChannel.cs @@ -0,0 +1,13 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; + +/// 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. +public interface ICipChannel +{ + /// Largest CIP request (service byte through end of data) one call may carry. + int MaxRequestBytes { get; } + + /// Send one CIP request and return the raw CIP reply bytes (reply-service byte first). + /// Throws on transport failure or timeout. + Task> SendAsync(ReadOnlyMemory cipRequest, CancellationToken cancellationToken); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/MultipleServicePacket.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/MultipleServicePacket.cs new file mode 100644 index 00000000..053e00b8 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/MultipleServicePacket.cs @@ -0,0 +1,47 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; + +/// Multiple Service Packet (service 0x0A on the Message Router). +public static class MultipleServicePacket +{ + /// Service + path size + Message Router path. + private const int HeaderSize = 6; + + public static byte[] Build(IReadOnlyList 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(); + } + + /// Exact byte size of an MSP carrying requests of the given sizes. + public static int BuildSize(IReadOnlyList requestSizes) + { + var total = HeaderSize + 2 + 2 * requestSizes.Count; + foreach (var s in requestSizes) total += s; + return total; + } + + /// Splits an MSP reply (the outer reply's data) into the embedded replies. + public static IReadOnlyList 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; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/RequestScheduler.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/RequestScheduler.cs new file mode 100644 index 00000000..f9102c21 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/RequestScheduler.cs @@ -0,0 +1,309 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; + +/// +/// Per-connection CIP request scheduler. Keeps exactly one request in flight on its +/// ; 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 +/// into one Multiple Service Packet. Each waiter receives its own embedded . +/// +/// +/// 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. +/// +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; + + /// True when this request must never share a Multiple Service Packet with another. + public bool Exclusive { get; } = exclusive; + public TaskCompletionSource Tcs { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public LinkedListNode? Node { get; set; } + + /// Stores the waiter's registration; disposes it at once if the waiter already completed. + public void SetRegistration(CancellationTokenRegistration registration, RequestScheduler owner) + { + lock (this) + { + if (!_completed) + { + _registration = registration; + _hasRegistration = true; + return; + } + } + registration.Dispose(); + Interlocked.Decrement(ref owner._liveRegistrations); + } + + /// Marks the waiter complete exactly once and releases its registration (finding 10). + /// Returns false if it was already completed. + 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 _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); + } + + /// Test hook: waiter cancellation registrations that have not yet been disposed. + internal int LiveRegistrations => Volatile.Read(ref _liveRegistrations); + + /// 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. + internal int CancellationCallbacksRun => Volatile.Read(ref _cancellationCallbacksRun); + + /// Test hook: requests still queued (not yet taken into a batch). + internal int QueuedCount { get { lock (_lock) return _queue.Count; } } + + /// + /// 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 when the channel + /// or the MSP envelope fails, or the scheduler is disposed; cancels only when fires. + /// + /// The CIP request. + /// Cancels this waiter only. + /// 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). + public Task SendAsync(byte[] request, CancellationToken ct, bool exclusive = false) + { + ArgumentNullException.ThrowIfNull(request); + if (ct.IsCancellationRequested) return Task.FromCanceled(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; + } + + /// Moves the next batch from the queue into . Filling a caller-owned + /// list means a throw part-way through still leaves every taken waiter reachable for faulting. + private void TakeBatch(List batch) + { + lock (_lock) + { + var sizes = new List(); + 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(); + 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 batch, Exception ex) + { + var error = ex as AbTransportException + ?? new AbTransportException(AbStatus.BadConnection, $"Request scheduler failed: {ex.Message}", ex); + List 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 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(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); + } + } + + /// + /// The error a waiter sees when the scheduler (i.e. its connection) goes away under it. Deliberately + /// NOT an : 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. + /// + private static AbTransportException DisposedError() => + new(AbStatus.BadConnection, "Scheduler disposed."); + + /// Stops the pump. In-flight and queued waiters fault with + /// (). + public async ValueTask DisposeAsync() + { + lock (_lock) + { + if (_disposed) return; + _disposed = true; + } + _stop.Cancel(); + try { await _pump.ConfigureAwait(false); } catch { /* stopping */ } + + List remaining; + lock (_lock) + { + remaining = [.. _queue]; + _queue.Clear(); + } + var error = DisposedError(); + foreach (var p in remaining) p.Fail(error, this); + _signal.Dispose(); + _stop.Dispose(); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/DeviceSessionPool.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/DeviceSessionPool.cs new file mode 100644 index 00000000..e2a2db66 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/DeviceSessionPool.cs @@ -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; + +/// +/// Process-wide owner of device connections: exactly one and one +/// per (review B1), handed out as ref-counted +/// 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. +/// +/// 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. +public sealed class DeviceSessionPool +{ + /// The process-wide pool the drivers use. Tests build their own. + public static DeviceSessionPool Shared { get; } = new(); + + internal sealed class Entry(CipConnection connection, RequestScheduler scheduler, ConnectionOptions options, + Task predecessorClosed) + { + public CipConnection Connection { get; } = connection; + /// Completes when every earlier connection for this key has finished closing. + 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 _entries = new(); + private readonly Dictionary _throttles = new(); + private readonly Dictionary _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; + } + + /// Give 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. + public static void ConfigureSharedLogger(ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(loggerFactory); + Shared._logger = loggerFactory.CreateLogger(); + } + + /// Wiring-test hook: the logger this pool currently writes to (final review I2). + internal ILogger Logger => _logger; + + /// Test hook: keys with a live entry. + internal int EntryCount { get { lock (_lock) return _entries.Count; } } + + /// Lease the shared connection for , 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 is logged once per entry. + 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 + } + } +} + +/// A reference on a device's shared connection. Dispose exactly once (further disposes are no-ops). +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; + + /// The device's channel. Prefer : it serializes and packs requests. + public ICipChannel Channel => _entry.Connection; + + /// The device's single request scheduler, shared by every lease on the key. + public RequestScheduler Scheduler => _entry.Scheduler; + + public ValueTask DisposeAsync() => + Interlocked.Exchange(ref _disposed, 1) == 0 ? _pool.ReleaseAsync(_key, _entry) : ValueTask.CompletedTask; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationClient.cs new file mode 100644 index 00000000..2b1be8b1 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationClient.cs @@ -0,0 +1,289 @@ +using System.Net.Sockets; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip; + +/// +/// One EtherNet/IP TCP session: connects, registers, and sends SendRRData (unconnected) and +/// SendUnitData (connected) requests with at most one request in flight. +/// +/// +/// Single flight. Every exchange (write one frame, read until its reply) runs under a +/// (1,1). The connected sequence count is advanced inside that gate, so +/// concurrent callers can never put sequences on the wire out of order. +/// Caller cancellation never faults the connection, and never misattributes a reply +/// (rulings R5-1/R5-2). The caller gets at once, but the +/// abandoned exchange keeps reading its own 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. +/// Faults. A timeout, an I/O error or an encapsulation-level protocol error +/// () closes the socket; turns false and +/// every later call throws (). +/// The owner reconnects with a fresh client. +/// No half-written frames. A frame write never observes a cancellation token. +/// +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; + + /// The session handle the target assigned in its RegisterSession reply. + public uint SessionHandle { get; private set; } + + /// False once the connection has faulted or been disposed. + public bool IsConnected => !_faulted && Volatile.Read(ref _disposed) == 0 && _tcp.Connected; + + private EncapsulationClient(TcpClient tcp) + { + _tcp = tcp; + _stream = tcp.GetStream(); + } + + /// Open the TCP connection and register an encapsulation session. + /// bounds the TCP connect and the RegisterSession exchange separately. + public static async Task 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; + } + } + + /// Send one unconnected (UCMM) CIP request and return the CIP reply bytes. + public async Task> SendRRDataAsync(ReadOnlyMemory 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; + } + + /// Send one connected (class 3) CIP request over and return + /// the CIP reply bytes. The client owns the connected sequence count (review B1). + public async Task> SendUnitDataAsync(uint otConnectionId, ReadOnlyMemory 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; + } + + /// The connected sequence count after : increments, wrapping + /// 65535 → 1 (0 is never put on the wire). + internal static ushort NextSequence(ushort current) => current == ushort.MaxValue ? (ushort)1 : (ushort)(current + 1); + + /// Acquire the gate, build the request (sequence assigned under the gate), then hand the + /// gate to , which owns its release from that point on. + private async Task<(EncapsulationHeader Header, ReadOnlyMemory Cip)> ExchangeAsync( + bool connected, Func build, TimeSpan timeout, CancellationToken ct) + { + ThrowIfUnusable(); + await _gate.WaitAsync(ct).ConfigureAwait(false); + + Task<(EncapsulationHeader, ReadOnlyMemory)> 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; + } + } + + /// Write one frame, then read frames until the matching reply arrives. Releases the gate + /// exactly once, in its finally. Never throws : every + /// failure surfaces as and faults the connection first. + private async Task<(EncapsulationHeader, ReadOnlyMemory)> 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(); + } + } + + /// Does 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. + private static bool TryMatch(byte[] frame, ushort cmd, bool connected, ulong ctx, ushort seq, + out (EncapsulationHeader, ReadOnlyMemory) 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 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 */ } + } + + /// Close without UnRegisterSession (registration never completed). + private void Abort() + { + Interlocked.Exchange(ref _disposed, 1); + Fault(); + } + + /// 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. + 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(); + } + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationCodec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationCodec.cs new file mode 100644 index 00000000..8b7d4f10 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationCodec.cs @@ -0,0 +1,115 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip; + +/// Builds and parses EtherNet/IP encapsulation frames, including the Common Packet Format +/// (CPF) items SendRRData / SendUnitData carry. +public static class EncapsulationCodec +{ + /// CPF item: Null Address (unconnected messaging). + public const ushort ItemNullAddress = 0x0000; + + /// CPF item: Connected Address (carries the O→T connection id). + public const ushort ItemConnectedAddress = 0x00A1; + + /// CPF item: Connected Data (sequence count + CIP). + public const ushort ItemConnectedData = 0x00B1; + + /// CPF item: Unconnected Data (CIP). + public const ushort ItemUnconnectedData = 0x00B2; + + /// 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. + 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(); + } + + /// RegisterSession: protocol version 1, option flags 0, and a zero sender context. + /// The sender context is always zero, as libplctag sends it (logix-register-session.hex): + /// 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. + public static byte[] RegisterSession() => + Finish(Header(EncapCommand.RegisterSession, 0, 0).U16(1).U16(0)); + + /// UnRegisterSession: header only, no data. + public static byte[] UnRegisterSession(uint session, ulong ctx) => + Finish(Header(EncapCommand.UnRegisterSession, session, ctx)); + + /// SendRRData (unconnected): Null Address item + Unconnected Data item carrying . + public static byte[] SendRRData(uint session, ulong ctx, ReadOnlySpan 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)); + + /// SendUnitData (connected): Connected Address item + Connected Data item carrying the + /// sequence count followed by . + public static byte[] SendUnitData(uint session, ulong ctx, uint connectionId, ushort sequence, ReadOnlySpan 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; + } + + /// Read the 24-byte header at the start of . + public static EncapsulationHeader ReadHeader(ReadOnlySpan b) + { + var r = new CipReader(b); + return new EncapsulationHeader(r.U16(), r.U16(), r.U32(), r.U32(), r.U64(), r.U32()); + } + + /// 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 () on a non-zero + /// encapsulation status, a length mismatch, a truncated item list or a missing data item. + public static (EncapsulationHeader Header, ReadOnlyMemory Cip, ushort Sequence) ParseReply( + ReadOnlyMemory 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.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."); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationHeader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationHeader.cs new file mode 100644 index 00000000..f55d1619 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationHeader.cs @@ -0,0 +1,15 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip; + +/// EtherNet/IP encapsulation command codes (CIP Vol. 2, Table 2-3.2). +public static class EncapCommand +{ + public const ushort ListIdentity = 0x0063, RegisterSession = 0x0065, UnRegisterSession = 0x0066, + SendRRData = 0x006F, SendUnitData = 0x0070; +} + +/// The fixed 24-byte EtherNet/IP encapsulation header (all fields little-endian). +public readonly record struct EncapsulationHeader( + ushort Command, ushort Length, uint SessionHandle, uint Status, ulong SenderContext, uint Options) +{ + public const int Size = 24; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixServices.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixServices.cs new file mode 100644 index 00000000..85dee883 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixServices.cs @@ -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; + +/// Logix Read/Write Tag (plain and fragmented) builders and the read-reply parser. +public static class LogixServices +{ + private static CipWriter Start(byte service, ReadOnlySpan path) => + new CipWriter().U8(service).U8((byte)(path.Length / 2)).Bytes(path); + + public static byte[] ReadTag(ReadOnlySpan path, ushort elements) => Start(0x4C, path).U16(elements).ToArray(); + + public static byte[] ReadTagFragmented(ReadOnlySpan path, ushort elements, uint offset) => + Start(0x52, path).U16(elements).U32(offset).ToArray(); + + public static byte[] WriteTag(ReadOnlySpan path, ushort type, ushort? handle, ushort elements, ReadOnlySpan 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 path, ushort type, ushort? handle, ushort elements, uint offset, ReadOnlySpan 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 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..]); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTagClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTagClient.cs new file mode 100644 index 00000000..4c8504aa --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTagClient.cs @@ -0,0 +1,136 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix; + +/// +/// Logix tag read/write over a connection's , including the fragment +/// loops for values that do not fit in one frame. +/// +/// +/// 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. +/// +public sealed class LogixTagClient(RequestScheduler scheduler, ICipChannel channel) +{ + private const byte StatusSuccess = 0x00; + private const byte StatusPartial = 0x06; + + /// Fixed bytes of a Write Tag Fragmented request besides path and data: service, path + /// size, type, struct handle, element count, offset. + private const int FragmentedWriteOverhead = 12; + + /// 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. + internal const int MaxReadBytes = 2 * 1024 * 1024; + + /// Chunk alignment when one element is wider than a frame (Logix lays structures out on + /// at most 8-byte boundaries). + 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)); + + /// + /// Read elements of the tag at the encoded . + /// A Partial (0x06) reply keeps its data and the read continues at offset = bytes received so + /// far 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. + /// + /// 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. + 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(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()); + } + + /// + /// Write to the tag at . One Write Tag when the + /// request fits ; otherwise Write Tag Fragmented chunks of + /// MaxRequestBytes - 12 - path.Length bytes aligned down to the element width (or to 8 bytes + /// when a single element is wider than a frame). + /// + /// 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. + /// For an atomic type, is not exactly + /// × the type's width. + public async Task WriteAsync(byte[] path, ushort type, ushort? handle, ushort elements, + ReadOnlyMemory 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}."); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTypeCode.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTypeCode.cs new file mode 100644 index 00000000..eeb9cfb9 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTypeCode.cs @@ -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; + + /// Byte width of an atomic type, or -1 when the code is not atomic (e.g. ). + 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, + }; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/SymbolObjectServices.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/SymbolObjectServices.cs new file mode 100644 index 00000000..dd1f853e --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/SymbolObjectServices.cs @@ -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; + +/// Symbol Object (class 0x6B) instance listing — the @tags enumeration. +public static class SymbolObjectServices +{ + /// Request attributes: 2 = type, 7 = element length, 8 = array dims, 1 = name. Identical + /// to what libplctag sends (logix-tags-listing.hex). + 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(); + } + + /// Entries are u32 instance, u16 type, u16 elemLen, u32×3 dims, u16 nameLen, name. + /// LastInstance is the last entry's instance id (next start = LastInstance + 1). + /// More is true on general status 0x06 (partial transfer). + 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); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/TemplateObjectServices.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/TemplateObjectServices.cs new file mode 100644 index 00000000..473e8804 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/TemplateObjectServices.cs @@ -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); + +/// Template Object (class 0x6C) attribute query and definition read. +public static class TemplateObjectServices +{ + /// Get Attribute List for attributes 4 (definition words), 5 (structure bytes), + /// 2 (member count) and 1 (handle). + 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); + } + + /// Read Template: 4C 03 20 6C 25 00 id offset bytes. + 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(); + + /// More ⇔ general status 0x06. + public static bool ReadHasMore(CipReply reply) => reply.GeneralStatus == 0x06; + + /// The buffer CipTemplateObjectDecoder expects: u16 id, u32 defWords, + /// u32 structBytes, u16 members, u16 handle then the raw template bytes. + public static byte[] BuildDecoderBuffer(ushort templateId, TemplateAttributes attrs, ReadOnlySpan rawTemplate) => + new CipWriter().U16(templateId).U32(attrs.DefinitionWords).U32(attrs.StructureBytes) + .U16(attrs.Members).U16(attrs.Handle).Bytes(rawTemplate).ToArray(); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccAddress.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccAddress.cs new file mode 100644 index 00000000..427251e7 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccAddress.cs @@ -0,0 +1,108 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc; + +/// Transport-side PCCC data-table address. Independent of the driver's AbLegacyAddress. +/// File letter(s): N, F, B, T, C, R, ST, L, A, I, O, S. +/// Explicit file number; null for O/I/S (implicit 0/1/2). +/// Element (word) number. +/// Word offset within a structured element (T/C: PRE=1, ACC=2; R: LEN=1, POS=2); null = 0. +/// Bit within the addressed word (/n or a status-bit sub-element), else null. +public sealed record PcccAddress(string FileLetter, int? FileNumber, int Element, int? SubElement, int? Bit = null) +{ + /// The PCCC file-type byte; throws Unsupported for an unknown letter. + public byte FileTypeCode => + PcccFileType.CodeFor(FileLetter) + ?? throw new AbTransportException(AbStatus.Unsupported, $"Unknown PCCC file type '{FileLetter}'."); + + /// Explicit file number, else the implicit one for O/I/S. + public int EffectiveFileNumber => + FileNumber ?? PcccFileType.ImplicitFileNumber(FileLetter) + ?? throw new AbTransportException(AbStatus.Unsupported, $"File type '{FileLetter}' needs an explicit file number."); + + /// The word offset to read within the element, and the bit within that word if any. + public (int Word, int? Bit) ResolveSubElement() => (SubElement ?? 0, Bit); + + /// Builds an address from a sub-element mnemonic (PRE, ACC, LEN, POS, or a status bit such as DN). + 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); + } + + /// Parses N7:0, B3:1/4, T4:0.ACC, I:0/1, ST9:2; null when malformed. + 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; } + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccFileType.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccFileType.cs new file mode 100644 index 00000000..fd7bcc8e --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccFileType.cs @@ -0,0 +1,39 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc; + +/// PCCC file-type codes for the protected typed logical read/write commands (0xA2/0xAA). +/// Wire evidence (final review I3), all against the ab_server 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). +/// 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. +public static class PcccFileType +{ + /// Returns the file-type code for a file letter (case-insensitive), or null when unknown. + 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, + }; + + /// The implicit file number for the single-letter system files (O=0, I=1, S=2), else null. + public static int? ImplicitFileNumber(string fileLetter) => fileLetter.ToUpperInvariant() switch + { + "O" => 0, + "I" => 1, + "S" => 2, + _ => null, + }; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccServices.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccServices.cs new file mode 100644 index 00000000..bfcd9065 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccServices.cs @@ -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; + +/// 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. +public static class PcccServices +{ + /// Largest data byte count one typed read/write may carry (single-byte count on the wire). + public const int MaxDataBytes = 236; + + /// True: PLC-5 range READ (cmd 0x0F fnc 0x01) is capture-verified. + public static bool Plc5Verified { get; } = true; + + /// False: no PLC-5 write capture exists, so no PLC-5 write builder ships. + public static bool Plc5WriteVerified { get; } = false; + + /// Requestor vendor id the native transport puts in Execute PCCC: libplctag's, from the captures + /// (07 3D F3 45 43 50 21 in slc500-read-n7.hex). + /// Not the CIP originator vendor (). ab_server answers every + /// PCCC request with this fixed requestor id (PCCC_RESP_PREFIX in its pccc.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 pass against both (found by + /// Task 17 slc500: with the 0x1337 originator identity every native PCCC reply was BadReply). + public const ushort RequestorVendor = 0xF33D; + + /// Requestor serial number paired with (libplctag's, from the captures). + public const uint RequestorSerial = 0x21504345; + + /// Wraps a PCCC command in Execute PCCC: 4B 02 20 67 24 01 + requestor id (7, vendor, serial) + PCCC. + public static byte[] ExecutePccc(ushort requestorVendor, uint requestorSerial, ReadOnlySpan 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); + } + + /// Protected typed logical read with 3 address fields (0x0F/0xA2). + 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(); + } + + /// 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). + public static byte[] SlcTypedWrite(ushort tns, PcccAddress addr, ReadOnlySpan 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(); + } + + /// PLC-5 range read (0x0F/0x01), as captured from libplctag: + /// offset u16 (0), totalTrans u16, levelMask u8 (0x06 = file+element), file*, element*, byteCount u8. + /// totalTrans 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. + 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(); + } + + /// Validates an Execute PCCC reply and returns the PCCC data bytes. + /// The parsed CIP reply. + /// The request's TNS; checked before STS so a stale error reply is BadReply. + /// Optional full echoed requestor id (length byte included) to compare. + public static ReadOnlyMemory 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..]; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipReader.cs new file mode 100644 index 00000000..e83d48b0 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipReader.cs @@ -0,0 +1,26 @@ +using System.Buffers.Binary; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire; + +/// Bounds-checked little-endian reader. Any overrun throws AbTransportException(BadReply). +public ref struct CipReader(ReadOnlySpan data) +{ + private readonly ReadOnlySpan _data = data; + public int Position { get; private set; } + public readonly int Remaining => _data.Length - Position; + + private ReadOnlySpan 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 Bytes(int n) => Take(n); + public ReadOnlySpan Rest() => Take(Remaining); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipWriter.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipWriter.cs new file mode 100644 index 00000000..3489b4bb --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipWriter.cs @@ -0,0 +1,29 @@ +using System.Buffers.Binary; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire; + +/// Growable little-endian writer for CIP / EIP frames. +public sealed class CipWriter +{ + private byte[] _buf = new byte[256]; + public int Length { get; private set; } + + private Span 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 v) { v.CopyTo(Take(v.Length)); return this; } + public CipWriter PadToEven() { if ((Length & 1) == 1) U8(0); return this; } + + /// Overwrite a u16 already written at (length back-patching). + public void PatchU16(int offset, ushort v) => BinaryPrimitives.WriteUInt16LittleEndian(_buf.AsSpan(offset, 2), v); + + public byte[] ToArray() => _buf.AsSpan(0, Length).ToArray(); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj new file mode 100644 index 00000000..cdf4d90e --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj @@ -0,0 +1,23 @@ + + + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport + ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport + + + + + + + + + + + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs index 4e730422..93d5a636 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs @@ -32,6 +32,23 @@ public sealed class AbCipDriverOptions /// public IReadOnlyList RawTags { get; init; } = []; + /// + /// Which transport stack the driver, its Test Connect probe and the CLI use. null (the + /// transport key absent) means "use the default": the node-level + /// Drivers:AbCip:DefaultTransport appsetting, which itself defaults to ; + /// an explicit value always wins. The effective transport is resolved in one place, + /// AbCipTransportResolver. Set to fall back. + /// Changing this takes effect when the driver instance is RECREATED, not on + /// ReinitializeAsync: 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 + /// (DriverSpawnPlanner, #516), so an operator edit does recreate it. + /// + /// 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. + [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + public AbCipTransport? Transport { get; init; } + /// Per-device probe settings. Falls back to defaults when omitted. public AbCipProbeOptions Probe { get; init; } = new(); @@ -119,11 +136,9 @@ public sealed class AbCipDriverOptions /// touching every other device on the same family. /// Per-device override for the Forward Open /// ConnectionSize (Large Forward Open packet size in bytes). null inherits the family -/// profile's DefaultConnectionSize. Honoured by the driver layer; the underlying -/// libplctag 1.5.2 wrapper has no direct ConnectionSize property, so the value is -/// plumbed through 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. +/// profile's DefaultConnectionSize. 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 +/// ConnectionSize property, so libplctag opens with its own size for the plc attribute. public sealed record AbCipDeviceOptions( string HostAddress, AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix, diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTransport.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTransport.cs new file mode 100644 index 00000000..1b346f26 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTransport.cs @@ -0,0 +1,14 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// 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. +/// +public enum AbCipTransport +{ + /// The libplctag-backed transport. The fallback; selected explicitly or via the node-level Drivers:AbCip:DefaultTransport. + Libplctag, + + /// The native managed EtherNet/IP + CIP transport (shared Driver.Ab.Transport stack). The default. + Native, +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 8afb0a38..f8f03953 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -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, /// Initializes a new instance of the class. /// The driver configuration options. /// A unique identifier for this driver instance. - /// Optional factory for creating tag runtimes; uses libplctag default if null. - /// Optional factory for enumerating tags; uses libplctag default if null. - /// Optional factory for reading UDT templates; uses libplctag default if null. + /// Optional factory for creating tag runtimes; when null the resolved transport's (see ) is used. + /// Optional factory for enumerating tags; when null the resolved transport's is used. + /// Optional factory for reading UDT templates; when null the resolved transport's is used. /// Optional logger; uses null logger if not provided. + /// The node-level default for an absent transport key + /// (Drivers:AbCip:DefaultTransport); null means the compiled . public AbCipDriver(AbCipDriverOptions options, string driverInstanceId, IAbCipTagFactory? tagFactory = null, IAbCipTagEnumeratorFactory? enumeratorFactory = null, IAbCipTemplateReaderFactory? templateReaderFactory = null, - ILogger? logger = null) + ILogger? 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.Instance; _resolver = new EquipmentTagRefResolver( 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); } + /// The tag factory chosen at construction; exposed so tests can pin transport selection. + internal IAbCipTagFactory TagFactory => _tagFactory; + + /// The effective transport resolved at construction (see ). + /// Describes the OPTION only: injected factories (tests) are used regardless. + 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 _typeMismatchWarned = + new(StringComparer.OrdinalIgnoreCase); + + /// Logs a type-mismatch Warning the first time reports one on this instance. + private void WarnTypeMismatchOnce(string tagName, string message, params object?[] args) + { + if (_typeMismatchWarned.TryAdd(tagName, 0)) + _logger.LogWarning(message, args); + } + /// Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8). 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, /// /// 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. /// @@ -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 from the device's cache and dispose /// it so the next read/write call re-creates and re-initializes a fresh handle. /// Called from , , and - /// after a non-zero libplctag status or transport exception — + /// after a non-zero transport status or transport exception — /// mirroring the probe loop's recreate-on-failure behaviour. /// 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. /// Review I-1 — the EXPLICIT array signal threaded through so a /// 1-element array ( 1) is still read as an array. + /// The tag definition's authored type, for the native runtime's + /// returned-type check; null skips it. /// The computed tag creation parameters. 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); /// Disposes all runtime tag handles and clears the caches. public void DisposeHandles() diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs index 6d8e176c..66236c62 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs @@ -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"; /// - /// Registers the AB CIP driver factory with the driver registry. + /// Registers the AB CIP driver factory with the driver registry. The optional + /// is captured at registration time and used to construct an + /// 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. /// /// The driver factory registry to register with. - public static void Register(DriverFactoryRegistry registry) + /// Optional logger factory for driver instances. + /// The node-level transport for drivers whose config has no + /// transport key (Drivers:AbCip:DefaultTransport, final review I1). An explicit + /// per-driver key still wins. + 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)); } /// @@ -31,15 +41,27 @@ public static class AbCipDriverFactoryExtensions /// The driver configuration as a JSON string. /// A configured instance. internal static AbCipDriver CreateInstance(string driverInstanceId, string driverConfigJson) + => CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null); + + /// Creates an instance of the AB CIP driver with an optional logger. + /// The unique identifier for this driver instance. + /// The driver configuration as a JSON string. + /// Optional logger factory for the driver instance. + /// The node-level default for an absent transport key. + /// A configured instance. + 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(), + defaultTransport: defaultTransport); } /// /// Deserialise an AB CIP driver-config JSON document into . - /// Shared by (first construction) and + /// Shared by (first construction) and /// / /// 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(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(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 ?? ""}' in '{driverInstanceId}' missing {field}"); + tagName is null + ? $"AB CIP {subject} missing {field}" + : $"AB CIP {subject} in '{driverInstanceId}' missing {field}"); } - return Enum.TryParse(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(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())}"); } @@ -128,6 +161,11 @@ public static class AbCipDriverFactoryExtensions internal sealed class AbCipDriverConfigDto { + /// + /// Gets or sets the transport (Libplctag or Native, case-insensitive); absent means the node default. + /// + public string? Transport { get; init; } + /// /// Gets or sets the timeout in milliseconds for operations. /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs index ccfa04f4..d0c21db7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs @@ -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; /// /// Two-phase Test-Connect probe for the -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 +/// 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 Ok = false with a "handshake failed" message instead of /// a false-positive green tick. /// -/// Status-code discrimination: libplctag statuses that indicate the controller answered -/// the CIP session but could not resolve the probe tag -/// (, , -/// ) are treated as "reachable — Ok = true" because -/// a non-existent tag name proves CIP connectivity. Statuses that indicate a transport or -/// session failure (, , -/// , , -/// , , -/// , , -/// , , -/// , ) are treated as -/// "handshake failed — Ok = false". All other error statuses default to -/// "handshake failed" (conservative). +/// Status-code discrimination: AbStatus.Ok and AbStatus.NotFound 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". /// /// 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; + + /// Initializes a new instance of the class. + /// Factory for the probe's tag runtime; when null the config's + /// Transport selects the native or libplctag-backed one. + /// The node-level default for a config with no transport key — the + /// SAME value the node's driver factory uses, so Test Connect probes the stack the driver would run. + public AbCipDriverProbe(IAbCipTagFactory? tagFactory = null, + AbCipTransport defaultTransport = AbCipTransportResolver.Default) + { + _tagFactory = tagFactory; + _defaultTransport = defaultTransport; + } + + /// The node-level default this probe resolves an absent key to (wiring tests). + public AbCipTransport DefaultTransport => _defaultTransport; + /// 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 } /// - /// Returns true for libplctag statuses that indicate the CIP controller answered + /// Returns true 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 true for (tag was found). + /// reachability. Returns true for Ok (tag was found). /// - 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; /// - /// Decides whether a is a "controller answered CIP but the + /// Decides whether an is a "controller answered CIP but the /// probe tag did not resolve" condition (reachable) or a session / transport failure - /// (unreachable), by recovering the the wrapper threw on and deferring - /// to — one classification, not two that can drift. + /// (unreachable), deferring to — one classification, not two. /// - /// The exception libplctag threw out of InitializeAsync. + /// The exception thrown out of InitializeAsync. /// true when the controller answered; false for a transport/session failure. - /// - /// The status arrives as the managed enum NAME. NativeTagWrapper.ThrowIfStatusNotOk - /// constructs the exception with Status.ToString(), so the message is exactly - /// "ErrorNotFound" — never the native C spelling "STATUS_NOT_FOUND" 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. - /// - 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) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs index bbe48a20..7b026d30 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs @@ -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). /// 0x16 object does not exist — BadNodeIdUnknown. /// 0x1E embedded service error — unwrap to the extended status when possible. -/// libplctag.NET errors — mapped per-member by -/// : timeout, not-found, not-allowed, and +/// Transport errors — mapped per-member by +/// : timeout, not-found, not-allowed, and /// out-of-bounds get their specific OPC UA codes; the remaining transport errors /// fold into BadCommunicationError. /// @@ -64,66 +64,42 @@ public static class AbCipStatusMapper }; /// - /// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here - /// is (int)Tag.GetStatus() — i.e. the underlying value of the libplctag.NET - /// enum, NOT a raw native PLCTAG_ERR_* constant. The wrapper - /// renumbers the native codes into a contiguous enum, so this method switches on the - /// members directly to stay correct if the wrapper renumbers again. - /// is success; 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 + /// , i.e. the underlying value of . /// - /// The libplctag status code as an integer. + /// The as an integer. /// The mapped OPC UA StatusCode value. - public static uint MapLibplctagStatus(int status) => MapLibplctagStatus((Status)status); + public static uint MapStatus(int status) => MapStatus((AbStatus)status); /// - /// Recover the libplctag a was thrown on. - /// The wrapper builds the exception from Status.ToString(), so the message is the enum - /// name ("ErrorNotFound"). + /// Map an to an OPC UA StatusCode. Timeout, not-found, not-allowed and + /// out-of-bounds get their specific codes; the remaining transport errors fold into + /// . /// - /// The exception. - /// The recovered status. - /// true when the message names a defined . - public static bool TryGetStatus(LibPlcTagException ex, out Status status) + /// The transport status. + /// The mapped OPC UA StatusCode value. + 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 + }; /// - /// Status for an exception thrown while creating / reading a tag: a libplctag exception maps - /// through its (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 (so a tag the controller does not have reads /// , not a comms error); anything else is /// . /// /// The exception. /// The OPC UA status code. public static uint MapException(Exception ex) => - ex is LibPlcTagException lpe && TryGetStatus(lpe, out var status) - ? MapLibplctagStatus(status) - : BadCommunicationError; - - /// - /// Map a libplctag.NET enum value to an OPC UA StatusCode. This is - /// the strongly-typed core of the mapper; the int overload exists only for the - /// seam, which returns the boxed-as-int value. - /// - /// The libplctag Status enum value. - /// The mapped OPC UA StatusCode value. - 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; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTransportFactories.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTransportFactories.cs new file mode 100644 index 00000000..6362875a --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTransportFactories.cs @@ -0,0 +1,14 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// Maps an to the matching trio of runtime factories. +internal static class AbCipTransportFactories +{ + /// Returns the tag, enumerator and template-reader factories for . + 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()), + }; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTransportResolver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTransportResolver.cs new file mode 100644 index 00000000..f633e761 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTransportResolver.cs @@ -0,0 +1,65 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// The ONE place an AbCip driver's effective transport is decided from its configured +/// . 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 +/// transport key means. +/// Precedence (final review I1): an explicit per-driver transport key wins; otherwise the +/// node-level default (Drivers:AbCip:DefaultTransport, read in the Host composition root and passed +/// in as nodeDefault); otherwise the compiled . +/// +public static class AbCipTransportResolver +{ + /// The compiled default: the transport an absent key selects when no node default is configured. + public const AbCipTransport Default = AbCipTransport.Native; + + /// Returns the effective transport: an explicit value always wins; null means . + /// The configured transport, or null for "use the default". + /// The node-level default for drivers with no key (the compiled unless pinned). + /// The transport the driver runs on. + public static AbCipTransport Resolve(AbCipTransport? configured, AbCipTransport nodeDefault = Default) => + configured ?? nodeDefault; + + /// Returns the effective transport for . + /// The driver options. + /// The node-level default for drivers with no key. + /// The transport the driver runs on. + public static AbCipTransport Resolve(AbCipDriverOptions options, AbCipTransport nodeDefault = Default) + { + ArgumentNullException.ThrowIfNull(options); + return Resolve(options.Transport, nodeDefault); + } + + /// + /// Parses a transport name the way the driver-config transport 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 — Enum.TryParse alone accepts both. + /// + /// The raw value. + /// The parsed transport. + /// True when names a transport. + 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() + .FirstOrDefault(n => string.Equals(n, raw.Trim(), StringComparison.OrdinalIgnoreCase)); + return name is not null && Enum.TryParse(name, out transport); + } + + /// + /// Operator-facing description of the configured-vs-effective transport for the init log line: + /// Native (default) when the key was absent and the compiled default applies, + /// Libplctag (node default) when the key was absent and the node pinned a different default, + /// Native (configured) when the key was explicit. + /// + /// The configured transport, or null. + /// The node-level default. + /// A short description. + public static string Describe(AbCipTransport? configured, AbCipTransport nodeDefault = Default) => + configured is not null ? $"{configured} (configured)" + : nodeDefault == Default ? $"{nodeDefault} (default)" + : $"{nodeDefault} (node default)"; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs index d3cbafa8..91e3f378 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs @@ -25,10 +25,10 @@ public interface IAbCipTagRuntime : IDisposable Task WriteAsync(CancellationToken cancellationToken); /// - /// Raw libplctag status code — mapped to an OPC UA StatusCode via - /// . Zero on success, negative on error. + /// Raw transport status code (an AbStatus value) — mapped to an OPC UA StatusCode via + /// . Zero on success, negative on error. /// - /// The raw libplctag status code (zero on success, negative on error). + /// The AbStatus value (zero on success, negative on error). int GetStatus(); /// @@ -119,6 +119,13 @@ public interface IAbCipTagFactory /// element count for an isArray tag — including a 1-element array. /// Review I-1 — the EXPLICIT array signal threaded from the tag definition so /// a 1-element array ( 1) is still read as an array, not a scalar. +/// 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 +/// TypeMismatch); libplctag ignores it. Null (enumeration, probes, BOOL-in-DINT parents) +/// skips the check. +/// From AbCipPlcFamilyProfile.SupportsConnectedMessaging at +/// every construction site, so the family profile stays the single authority for the native +/// transport's routing choice. libplctag ignores it. 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) { /// /// The value for libplctag's gateway attribute: host:port, or bare host when diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagOrphanRelease.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagOrphanRelease.cs new file mode 100644 index 00000000..f0defa6e --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagOrphanRelease.cs @@ -0,0 +1,143 @@ +using System.Collections.Concurrent; +using System.Reflection; +using libplctag; +using libplctag.NativeImport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// Releases the native tag a FAILED libplctag initialisation leaves behind (T21 press pass 1 crash). +/// +/// +/// libplctag.NET 1.5.2's NativeTagWrapper.InitializeAsync calls plc_tag_create_ex, which returns a +/// native handle with the wrapper's callback_func_ex delegate registered, and only then waits for the +/// Created event. When that event carries an error (a tag the controller does not have, a refused path, ...) +/// it throws WITHOUT setting _isInitialized, and Dispose() unregisters and destroys only +/// if (_isInitialized). 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 DESTROYED event from plc_tag_shutdown, which the native library runs from +/// atexit — 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. +/// The wrapper exposes no handle, so it is read by reflection from the two private fields of the pinned +/// 1.5.2 wrapper (Tag._tag, NativeTagWrapper.nativeTagHandle). 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 LibplctagOrphanReleaseTests fails, so the bump is noticed. +/// +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; + + /// Tags whose handle could not be read: kept reachable so their callback delegate is never collected. + private static readonly ConcurrentBag Rooted = []; + + private static int _released; + + /// True when the pinned wrapper's private fields were found, so a failed tag can be destroyed. + internal static bool CanReadHandles => HandleField is not null && !ForceRootingForTest; + + /// Test seam (review r1 M1): take the rooting fallback as if the wrapper's fields had been renamed. + internal static bool ForceRootingForTest { get; set; } + + /// Orphaned native tags destroyed so far (process-wide; diagnostics and tests). + internal static int Released => Volatile.Read(ref _released); + + /// Orphaned tags rooted for the process lifetime because their handle could not be read. + internal static int RootedCount => Rooted.Count; + + /// 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. + 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; + } + } + + /// Clears the wrapper's handle after a destroy, so a repeat release is a no-op (review r1 M2). + private static void ClearHandle(Tag tag) + { + if (HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper) HandleField.SetValue(wrapper, 0); + } + + /// + /// Runs a libplctag operation that may initialise (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. + /// + internal static async Task RunAsync(Tag tag, Func op) + { + try + { + await op().ConfigureAwait(false); + } + catch + { + ReleaseIfOrphaned(tag); + throw; + } + } + + /// + internal static async Task RunAsync(Tag tag, Func> op) + { + try + { + return await op().ConfigureAwait(false); + } + catch + { + ReleaseIfOrphaned(tag); + throw; + } + } + + /// + /// Destroys the native tag behind an UNINITIALISED wrapper, unregistering its callback first. No-op for an + /// initialised tag (its own Dispose handles it) or one that never got a handle. Never throws. + /// + /// The status the native tag held just before it was destroyed (what GetStatus() would have + /// reported), or null when nothing was destroyed. + 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; + } + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagPath.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagPath.cs new file mode 100644 index 00000000..69414ab7 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagPath.cs @@ -0,0 +1,13 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// Normalizes a parsed CIP route for libplctag's Path attribute. libplctag rejects an +/// empty-string path with ErrorBadData; "no route" (Micro800, unrouted MicroLogix) must be +/// expressed by omitting the attribute, i.e. . +/// +internal static class LibplctagPath +{ + /// Returns for a null, empty or whitespace path; otherwise the path unchanged. + /// The parsed CIP path. + public static string? Normalize(string? path) => string.IsNullOrWhiteSpace(path) ? null : path; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs new file mode 100644 index 00000000..3d695bdd --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs @@ -0,0 +1,76 @@ +using libplctag; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// The ONLY place libplctag's meets . +internal static class LibplctagStatusTranslation +{ + /// Translates a libplctag status into the driver-owned . + /// The libplctag status. + /// The equivalent transport-neutral status. + 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, + }; + + /// + /// Recovers the status a was thrown on. libplctag builds the + /// exception from Status.ToString(), so the message is the enum name. + /// + /// The exception. + /// The translated status, or when the message names no status. + public static AbStatus ToAbStatus(LibPlcTagException ex) => + Enum.TryParse((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out var s) && Enum.IsDefined(s) + ? ToAbStatus(s) + : AbStatus.BadConnection; + + /// Runs , rethrowing any as . + /// The libplctag operation. + /// A task completing when the operation does. + public static async Task Wrap(Func op) + { + try { await op().ConfigureAwait(false); } + catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); } + } + + /// Runs , rethrowing any as . + /// The result type. + /// The libplctag operation. + /// The operation result. + public static async Task Wrap(Func> op) + { + try { return await op().ConfigureAwait(false); } + catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); } + } + + /// Runs a synchronous libplctag call, rethrowing as . + /// The result type. + /// The libplctag call. + /// The call result. + public static T WrapSync(Func op) + { + try { return op(); } + catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); } + } + + /// Runs a synchronous libplctag call, rethrowing as . + /// The libplctag call. + public static void WrapSync(Action op) + { + try { op(); } + catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); } + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs index e2858385..11dd941a 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs @@ -1,85 +1,24 @@ -using System.Runtime.CompilerServices; using libplctag; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; /// -/// Real that walks a Logix controller's symbol table by -/// reading the @tags pseudo-tag via libplctag + decoding the CIP Symbol Object -/// response with . +/// libplctag listing reader: reads a @tags / Program:X.@tags pseudo-tag and returns +/// its raw Symbol Object bytes for . /// -/// -/// libplctag's Tag.GetBuffer() returns the raw Symbol Object bytes when the -/// tag name is @tags. The decoder walks the concatenated entries + emits -/// records matching our driver surface. -/// -/// remains available for tests that don't want -/// to touch the native library; the production factory default -/// () wires this implementation in. -/// -internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator +internal static class LibplctagListingReader { - private readonly Func> _readListing; - - /// Production enumerator: reads each listing through libplctag. - public LibplctagTagEnumerator() : this(ReadListingAsync) { } - - /// Enumerator over an injected listing reader (unit-test seam). - /// Reads one symbol listing (@tags or Program:X.@tags) and - /// returns its raw Symbol Object bytes. - internal LibplctagTagEnumerator(Func> readListing) - => _readListing = readListing; - - /// - /// - /// A controller's @tags listing holds controller-scope tags plus one system entry per - /// program (Program:UN01_Press); the program's own tags are listed only by - /// Program:<name>.@tags. 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. - /// - public async IAsyncEnumerable 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(); - 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 ReadListingAsync( + internal static async Task 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); } /// @@ -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, }; - /// Nothing to release — each listing's libplctag tag is disposed after its read. - public void Dispose() { } - private static PlcType MapPlcType(string attribute) => attribute switch { "controllogix" => PlcType.ControlLogix, @@ -118,9 +54,9 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator }; } -/// Factory for . +/// Factory for the libplctag-backed . internal sealed class LibplctagTagEnumeratorFactory : IAbCipTagEnumeratorFactory { /// - public IAbCipTagEnumerator Create() => new LibplctagTagEnumerator(); + public IAbCipTagEnumerator Create() => new SymbolListingEnumerator(LibplctagListingReader.ReadAsync); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs index b503dc03..d1c4436b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs @@ -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; + /// + /// The status the native tag held when a failed initialisation destroyed it (see + /// ); null while the wrapper's own handle is authoritative. + /// + private Status? _releasedStatus; + + /// The native handle a failed initialisation destroyed (0 if none); the wrapper's own field is cleared. + private int _releasedHandle; + /// Initializes a new instance of the LibplctagTagRuntime class. /// The tag creation parameters. public LibplctagTagRuntime(AbCipTagCreateParams p) { - _tag = new Tag + _tag = BuildTag(p); + } + + /// Builds the (uninitialized) libplctag tag for . + /// The tag creation parameters. + 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, }; + + /// + public Task InitializeAsync(CancellationToken cancellationToken) => + RunReleasingOrphanAsync(() => _tag.InitializeAsync(cancellationToken)); + + /// + /// libplctag initialises an uninitialised tag on its first read, so this path can orphan one too. + public Task ReadAsync(CancellationToken cancellationToken) => + RunReleasingOrphanAsync(() => _tag.ReadAsync(cancellationToken)); + + /// + public Task WriteAsync(CancellationToken cancellationToken) => + RunReleasingOrphanAsync(() => _tag.WriteAsync(cancellationToken)); + + /// + /// 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 . + /// + private async Task RunReleasingOrphanAsync(Func 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; + } } - /// - public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken); + /// The native libplctag handle this runtime's tag holds, or the one a failed initialisation destroyed + /// (0 if none was created). Tests only. + internal int NativeHandle => _releasedHandle != 0 ? _releasedHandle : LibplctagOrphanRelease.HandleOf(_tag); + + /// The handle the libplctag wrapper itself still holds (0 once a release cleared it). Tests only. + internal int WrapperHandle => LibplctagOrphanRelease.HandleOf(_tag); /// - public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken); + public int GetStatus() => + (int)LibplctagStatusTranslation.ToAbStatus(_releasedStatus ?? _tag.GetStatus()); /// - public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken); + public int GetSize() => LibplctagStatusTranslation.WrapSync(() => _tag.GetSize()); - /// - public int GetStatus() => (int)_tag.GetStatus(); - - /// - public int GetSize() => _tag.GetSize(); + /// + /// 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 . + /// + internal ReadOnlyMemory RawBuffer => LibplctagStatusTranslation.WrapSync(() => _tag.GetBuffer()); /// public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex); /// - 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 }; /// - 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 }; /// - 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) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTemplateReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTemplateReader.cs index 5af05c01..26f3d838 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTemplateReader.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTemplateReader.cs @@ -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); } /// Builds the (uninitialized) libplctag tag for the @udt/{id} pseudo. @@ -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}", diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipDevice.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipDevice.cs new file mode 100644 index 00000000..0a99ab2d --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipDevice.cs @@ -0,0 +1,55 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// Maps 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. +/// +internal static class NativeAbCipDevice +{ + /// The EtherNet/IP default TCP port, used when no usable port was configured. + internal const int DefaultPort = 44818; + + /// The plc= attribute of the Micro800 family profile. + private const string Micro800Attribute = "micro800"; + + /// The pool key: one CIP connection per (host, port, cipPath, plcAttribute). + /// The tag creation parameters. + /// The device key. + public static DeviceKey Key(AbCipTagCreateParams p) => + new(p.Gateway, p.Port > 0 ? p.Port : DefaultPort, p.CipPath, p.LibplctagPlcAttribute); + + /// The connection settings for the device addresses. + /// The tag creation parameters. + /// The connection options. + public static ConnectionOptions Options(AbCipTagCreateParams p) => new() + { + Routing = RoutingFor(p), + Timeout = p.Timeout, + ConnectionSize = p.ConnectionSize, + AllowPacking = p.AllowPacking, + }; + + /// + /// How requests reach the controller's message router. + /// + /// + /// Micro800 is always with an empty route, even though its + /// family profile says SupportsConnectedMessaging: false: libplctag opens a Large Forward + /// Open with connection path 20 02 24 01 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. + /// + /// The tag creation parameters. + /// The routing mode. + 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; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagEnumerator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagEnumerator.cs new file mode 100644 index 00000000..c05d3259 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagEnumerator.cs @@ -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; + +/// +/// Native : one 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. +/// +internal sealed class NativeAbCipTagEnumerator : IAbCipTagEnumerator +{ + private readonly Func Release)> _acquire; + private readonly ILogger _logger; + + /// Production constructor: leases from . + /// The connection pool (the factory passes ). + public NativeAbCipTagEnumerator(DeviceSessionPool pool) + : this(p => + { + var lease = pool.Acquire(NativeAbCipDevice.Key(p), NativeAbCipDevice.Options(p)); + return (lease.Scheduler, lease.DisposeAsync); + }) + { + } + + /// Test seam: supplies the scheduler and its release directly. + /// Acquires the scheduler for a device, with the callback that releases it. + /// Receives release failures; null discards them. + internal NativeAbCipTagEnumerator( + Func Release)> acquire, + ILogger? logger = null) + { + _acquire = acquire; + _logger = logger ?? NullLogger.Instance; + } + + /// + public async IAsyncEnumerable 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); + } + } + + /// Nothing to release — each enumeration owns and releases its own lease. + public void Dispose() { } +} + +/// Pages a symbol listing off the controller into the concatenated Symbol Object entry bytes. +internal static class NativeListingReader +{ + private const string TagsSuffix = ".@tags"; + private const string ProgramPrefix = "Program:"; + + /// Page cap for one listing; a real controller needs a small fraction of this. + internal const int MaxPages = 10_000; + + /// Byte cap for one listing (64 MiB). + internal const long MaxListingBytes = 64L * 1024 * 1024; + + /// Reads @tags or Program:X.@tags in full. + /// The device's shared scheduler. + /// The listing pseudo-tag name. + /// Cancels the read. + /// The raw entries, byte for byte what expects. + public static async Task 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."); + } +} + +/// Factory for . +internal sealed class NativeAbCipTagEnumeratorFactory : IAbCipTagEnumeratorFactory +{ + /// + public IAbCipTagEnumerator Create() => new NativeAbCipTagEnumerator(DeviceSessionPool.Shared); +} + +/// Bounded lease release shared by the native enumerator and template reader. +internal static class NativeLease +{ + /// Upper bound on waiting for the lease release (and its Forward Close). + private static readonly TimeSpan DisposeWait = TimeSpan.FromSeconds(2); + + /// Releases, waiting at most 2 s; never throws, whatever the release delegate does. + public static void ReleaseBounded(Func release, ILogger logger) + { + try { release().AsTask().Wait(DisposeWait); } + catch (Exception ex) + { + logger.LogWarning(ex, "Releasing the native AbCip device lease failed."); + } + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagFactory.cs new file mode 100644 index 00000000..9e84daaa --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagFactory.cs @@ -0,0 +1,18 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// for the native transport: every runtime leases its device's +/// connection from , so all tags on one device share one CIP +/// connection and one request scheduler. Stateless; safe to share across devices. +/// +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; + + /// + public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) => + new NativeAbCipTagRuntime(createParams, _pool); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs new file mode 100644 index 00000000..60910133 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs @@ -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; + +/// +/// Native over the managed EtherNet/IP + CIP stack. Leases the +/// device's shared connection from and reads/writes through a +/// per-runtime over the lease's shared scheduler, so concurrent +/// tags on one device coalesce into Multiple Service Packets (review B1). Decoding mirrors +/// byte for byte; unlike libplctag it also checks the CIP type +/// code the controller returned against the authored type. +/// +/// +/// 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. +/// +internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime +{ + /// Upper bound on waiting for the lease release (and its Forward Close) in . + private static readonly TimeSpan DisposeWait = TimeSpan.FromSeconds(2); + + /// Bytes of a Logix STRING's length word. + 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; + + /// Production constructor; leases the connection from . + /// The tag creation parameters. + /// The connection pool (the driver passes ). + 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); + } + + /// + /// Test seam: a runtime over with a private scheduler and no pool + /// lease. Production code always goes through . + /// + /// The channel to send on. + /// The tag creation parameters. + /// The runtime. + 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; + } + + /// The CIP type code of the last successful read, if any. + internal ushort? TypeCode => _typeCode; + + /// The structure handle of the last successful read, if the controller returned one. + internal ushort? StructHandle => _structHandle; + + /// The trailing bit selector split off the tag name (Tag.5), if any. + internal int? ImplicitBit => _implicitBit; + + /// + /// + /// Performs one read to prove the tag exists: libplctag's InitializeAsync errors for an + /// unknown tag, and the driver's 05/STAB-8 backoff classification depends on create throwing + /// . + /// + 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); + } + + /// + 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; + } + } + + /// + 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; + } + } + + /// + public int GetStatus() => (int)_status; + + /// + public int GetSize() => _data.Length; + + /// + /// The bytes of the last read, for the read-only differential harness (Task 18) to compare against + /// libplctag byte for byte. Not part of . + /// + internal ReadOnlyMemory RawBuffer => _data; + + /// + public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex); + + /// + 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, + }; + } + + /// + 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(count, stride, type), + AbCipDataType.UDInt => BuildArray(count, stride, type), + AbCipDataType.LInt => BuildArray(count, stride, type), + AbCipDataType.ULInt => BuildArray(count, stride, type), + AbCipDataType.Real => BuildArray(count, stride, type), + AbCipDataType.LReal => BuildArray(count, stride, type), + AbCipDataType.String => BuildArray(count, stride, type), + _ => null, + }; + } + + /// + 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."); + } + } + + /// + /// 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. + /// + 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."); + } + + /// + /// 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 (Flags.3 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. + /// + 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; + } + + /// Micro800 bit-string type codes (DWORD is , 0xD3). + private const ushort ByteCode = 0xD1, WordCode = 0xD2, LWordCode = 0xD4; + + /// Width of an integer or bit-string code; null for anything else (BOOL, REAL, ...). + 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, + }; + + /// The Logix type code an authored type expects, or null when it is exempt from checking. + 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, + }; + + /// Bit of the little-endian buffer. + 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; + } + + /// The bytes at ; OutOfBounds like libplctag. + private ReadOnlySpan 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); + } + + /// Logix STRING: u32 LEN then ASCII DATA, LEN clamped to what the buffer holds. + 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); + } + + /// + /// u32 len + 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. + /// + 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(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; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTemplateReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTemplateReader.cs new file mode 100644 index 00000000..0a9d5bbe --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTemplateReader.cs @@ -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; + +/// +/// Native : Get Attribute List then chunked Read Template on the +/// device's shared scheduler, returning the 14-byte-header buffer +/// parses. The lease is held for one read and released when it completes. +/// +internal sealed class NativeAbCipTemplateReader : IAbCipTemplateReader +{ + /// Request bytes of a Read Template that are not data room (service, path, offset, count). + private const int RequestOverhead = 16; + + /// Bytes of the template definition header not counted in the Read Template length. + private const int DefinitionHeaderBytes = 23; + + /// Upper bound on a template's Read Template length; Logix templates are far smaller. + internal const long MaxTemplateBytes = 64 * 1024; + + private readonly Func Release)> _acquire; + private readonly ILogger _logger; + + /// Production constructor: leases from . + /// The connection pool (the factory passes ). + public NativeAbCipTemplateReader(DeviceSessionPool pool) + : this(p => + { + var lease = pool.Acquire(NativeAbCipDevice.Key(p), NativeAbCipDevice.Options(p)); + return (lease.Scheduler, lease.Channel, lease.DisposeAsync); + }) + { + } + + /// Test seam: supplies the scheduler, channel and release directly. + /// Acquires the connection parts for a device. + /// Receives release failures; null discards them. + internal NativeAbCipTemplateReader( + Func Release)> acquire, + ILogger? logger = null) + { + _acquire = acquire; + _logger = logger ?? NullLogger.Instance; + } + + /// + public async Task 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); + } + } + + /// Nothing to release — each read owns and releases its own lease. + public void Dispose() { } +} + +/// Factory for . +internal sealed class NativeAbCipTemplateReaderFactory : IAbCipTemplateReaderFactory +{ + /// + public IAbCipTemplateReader Create() => new NativeAbCipTemplateReader(DeviceSessionPool.Shared); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs index acaede0d..90d10293 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/PlcFamilies/AbCipPlcFamilyProfile.cs @@ -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, diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/SymbolListingEnumerator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/SymbolListingEnumerator.cs new file mode 100644 index 00000000..24041170 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/SymbolListingEnumerator.cs @@ -0,0 +1,74 @@ +using System.Runtime.CompilerServices; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// Transport-neutral that walks a Logix controller's symbol table +/// by reading the @tags listing through an injected reader + decoding the CIP Symbol Object +/// bytes with . +/// +/// +/// The reader returns the raw Symbol Object bytes for a listing name (libplctag's +/// Tag.GetBuffer() for @tags, or the native paged Instance Attribute List). The decoder walks the concatenated entries + emits +/// records matching our driver surface. +/// +/// remains available for tests that don't want +/// to touch the native library; the production factory default +/// () wires this implementation in. +/// +internal sealed class SymbolListingEnumerator : IAbCipTagEnumerator +{ + private readonly Func> _readListing; + + /// Enumerator over a transport-specific listing reader. + /// Reads one symbol listing (@tags or Program:X.@tags) and + /// returns its raw Symbol Object bytes. + public SymbolListingEnumerator(Func> readListing) + => _readListing = readListing; + + /// + /// + /// A controller's @tags listing holds controller-scope tags plus one system entry per + /// program (Program:UN01_Press); the program's own tags are listed only by + /// Program:<name>.@tags. 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. + /// + public async IAsyncEnumerable 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(); + 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; + } + } + + /// Nothing to release — the reader owns any per-listing resources. + public void Dispose() { } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj index 50b2cae4..59436b5e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj @@ -14,6 +14,7 @@ + @@ -27,6 +28,7 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs index c9376a09..ab5b9d09 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs @@ -25,6 +25,23 @@ public sealed class AbLegacyDriverOptions /// public IReadOnlyList RawTags { get; init; } = []; + /// + /// Which transport stack the driver, its Test Connect probe and the CLI use. null (the + /// transport key absent) means "use the default": for + /// every family. 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, AbLegacyTransportResolver. + /// Changing this takes effect when the driver instance is RECREATED, not on + /// ReinitializeAsync: 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 + /// (DriverSpawnPlanner, #516), so an operator edit does recreate it. + /// + /// 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. + [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + public AbLegacyTransport? Transport { get; init; } + /// Gets or sets the probe (connectivity check) options. public AbLegacyProbeOptions Probe { get; init; } = new(); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTransport.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTransport.cs new file mode 100644 index 00000000..0ba96d15 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTransport.cs @@ -0,0 +1,17 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +/// +/// 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. +/// +public enum AbLegacyTransport +{ + /// The libplctag-backed transport. The default. + Libplctag, + + /// + /// 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. + /// + Native, +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 1befff77..57c6ba1d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -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.Instance; _resolver = new EquipmentTagRefResolver( r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null); @@ -85,6 +92,23 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover new KeyValuePair(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, driverInstanceId))); } + /// The tag factory chosen at construction; exposed so tests can pin transport selection. + internal IAbLegacyTagFactory TagFactory => _tagFactory; + + /// The effective transport resolved at construction (see ). + /// Describes the OPTION only: an injected tagFactory (tests) is used regardless. + 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; + /// Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8). 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; + /// + /// True for a deterministic refusal of the operation itself () — 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. + /// + internal static bool IsLocalRefusal(Exception ex) => + ex is AbTransportException { Status: AbStatus.Unsupported }; + + /// + /// 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. + /// + 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 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 diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs index cf55b578..e7093f76 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs @@ -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(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(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(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 /// public int? TimeoutMs { get; init; } + /// + /// Gets or sets the transport (Libplctag or Native, case-insensitive); absent means Libplctag (the default). + /// + public string? Transport { get; init; } + /// /// Gets or sets the list of devices to connect to. /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs index fadeab3a..d1b05167 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs @@ -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 Ok = false with a "handshake failed" message instead of /// a false-positive green tick. /// -/// 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 -/// (, , -/// ) are treated as "reachable — Ok = true" because +/// () are treated as "reachable — Ok = true" because /// a non-existent tag name proves PCCC connectivity. Statuses that indicate a transport or -/// session failure (, , -/// , , -/// , , -/// , , -/// , , -/// , ) are treated as +/// session failure (, , +/// , , ) are treated as /// "handshake failed — Ok = false". All other error statuses default to /// "handshake failed" (conservative). /// /// public sealed class AbLegacyDriverProbe : IDriverProbe { + private readonly IAbLegacyTagFactory? _tagFactory; + + /// Initializes a new instance of the class. + /// The tag factory used for the PCCC handshake; when null the config's + /// Transport selects the native or libplctag-backed one. + 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 } /// - /// Returns true 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 true for (tag was found). + /// Returns true for statuses that indicate the PCCC controller answered but the + /// probe tag was not found — sufficient to confirm PCCC reachability. Also true for + /// (tag was found). /// - private static bool IsReachableStatus(Status s) => s is - Status.Ok or - Status.ErrorNotFound or - Status.ErrorNoMatch or - Status.ErrorBadDevice; - - /// - /// Inspects a 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. - /// - 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); - } + /// The transport status. + /// Whether the status proves the controller answered. + internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound; private static (AbLegacyHostAddress? parsed, AbLegacyDeviceOptions? device) ExtractTarget(AbLegacyDriverOptions opts) { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyStatusMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyStatusMapper.cs index 9402a82e..d5746476 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyStatusMapper.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyStatusMapper.cs @@ -1,9 +1,9 @@ -using libplctag; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; /// -/// 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). /// @@ -22,44 +22,41 @@ public static class AbLegacyStatusMapper public const uint BadTypeMismatch = 0x80740000u; /// - /// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here - /// is (int)Tag.GetStatus() — the underlying value of the libplctag.NET - /// 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 (what + /// returns) to an OPC UA StatusCode. /// - /// The integer status code from libplctag. + /// The integer transport status. /// The corresponding OPC UA status code. - public static uint MapLibplctagStatus(int status) => MapLibplctagStatus((Status)status); + public static uint MapStatus(int status) => MapStatus((AbStatus)status); - /// - /// Map a libplctag.NET enum value to an OPC UA StatusCode. This is - /// the canonical core; the int overload exists only for the - /// seam which boxes the enum as an int. - /// - /// The libplctag Status enum value. + /// Map a transport-neutral to an OPC UA StatusCode. + /// The transport status. /// The corresponding OPC UA status code. - 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 }; + /// Map an exception thrown by a transport to an OPC UA StatusCode. + /// The exception. + /// The mapped status for an , else BadCommunicationError. + public static uint MapException(Exception ex) => + ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError; + /// /// 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 enum rather than exposing + /// the transports surface only an 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. /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyTransportResolver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyTransportResolver.cs new file mode 100644 index 00000000..58c14e74 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyTransportResolver.cs @@ -0,0 +1,57 @@ +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +/// +/// The ONE place an AbLegacy driver's effective transport is decided from its configured +/// . The driver constructor, the Test Connect probe and the +/// CLI (through the driver) all resolve through here. +/// An absent key means = 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 +/// (). +/// +public static class AbLegacyTransportResolver +{ + /// Returns true for a family whose native transport support is limited. + /// The PLC family. + /// True for PLC-5 and LogixPccc. + public static bool IsNativeLimited(AbLegacyPlcFamily family) => + family is AbLegacyPlcFamily.Plc5 or AbLegacyPlcFamily.LogixPccc; + + /// Returns the distinct native-limited families among . + /// The configured devices. + /// The limited families present, in declaration order. + public static IReadOnlyList NativeLimitedFamilies(IEnumerable devices) + { + ArgumentNullException.ThrowIfNull(devices); + return [.. devices.Select(d => d.PlcFamily).Where(IsNativeLimited).Distinct()]; + } + + /// The transport an absent / null transport key selects (all families). + public const AbLegacyTransport Default = AbLegacyTransport.Libplctag; + + /// Returns the effective transport: an explicit value always wins; null means . + /// The configured transport, or null for "use the default". + /// The transport the driver runs on. + public static AbLegacyTransport Resolve(AbLegacyTransport? configured) => configured ?? Default; + + /// Returns the effective transport for . + /// The driver options. + /// The transport the driver runs on. + public static AbLegacyTransport Resolve(AbLegacyDriverOptions options) + { + ArgumentNullException.ThrowIfNull(options); + return Resolve(options.Transport); + } + + /// + /// Operator-facing description of the configured-vs-effective transport for the init log line: + /// Libplctag (default) when the key was absent, Native (configured) when explicit. + /// + /// The configured transport, or null. + /// A short description. + public static string Describe(AbLegacyTransport? configured) => + configured is null ? $"{Resolve(configured)} (default)" : $"{configured} (configured)"; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs new file mode 100644 index 00000000..3d762e84 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs @@ -0,0 +1,45 @@ +using libplctag; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +/// The ONLY place libplctag's meets in AbLegacy. +internal static class LibplctagLegacyStatusTranslation +{ + /// Translates a libplctag status into the transport-neutral . + /// The libplctag status. + /// The corresponding . + 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, + }; + + /// libplctag builds the exception from Status.ToString(), so the message is the enum name. + /// The libplctag exception. + /// The corresponding . + public static AbStatus ToAbStatus(LibPlcTagException ex) => + Enum.TryParse((ex.Message ?? "").Trim(), ignoreCase: true, out var s) && Enum.IsDefined(s) + ? ToAbStatus(s) + : AbStatus.BadConnection; + + /// Runs , rethrowing libplctag failures as . + /// The libplctag operation. + /// A task completing with the operation. + public static async Task Wrap(Func op) + { + try { await op().ConfigureAwait(false); } + catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); } + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs index fb71ce32..4067d0b2 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs @@ -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; + /// + /// The status the native tag held when a failed initialisation destroyed it (see + /// ); null while the wrapper's own handle is authoritative. + /// + private Status? _releasedStatus; + + /// The native handle a failed initialisation destroyed (0 if none); the wrapper's own field is cleared. + private int _releasedHandle; + /// Initializes a new instance of the class. /// The parameters for tag creation. public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p) { - _tag = new Tag + _tag = BuildTag(p); + } + + /// Builds the (uninitialized) libplctag tag for . + /// The tag creation parameters. + 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, }; + + /// + public Task InitializeAsync(CancellationToken cancellationToken) => + RunReleasingOrphanAsync(() => _tag.InitializeAsync(cancellationToken)); + + /// + /// libplctag initialises an uninitialised tag on its first read, so this path can orphan one too. + public Task ReadAsync(CancellationToken cancellationToken) => + RunReleasingOrphanAsync(() => _tag.ReadAsync(cancellationToken)); + + /// + public Task WriteAsync(CancellationToken cancellationToken) => + RunReleasingOrphanAsync(() => _tag.WriteAsync(cancellationToken)); + + /// + /// 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 . + /// + private async Task RunReleasingOrphanAsync(Func 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; + } } - /// - public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken); + /// The native libplctag handle this runtime's tag holds, or the one a failed initialisation destroyed + /// (0 if none was created). Tests only. + internal int NativeHandle => _releasedHandle != 0 ? _releasedHandle : LibplctagOrphanRelease.HandleOf(_tag); + + /// The handle the libplctag wrapper itself still holds (0 once a release cleared it). Tests only. + internal int WrapperHandle => LibplctagOrphanRelease.HandleOf(_tag); /// - public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken); - - /// - public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken); - - /// - public int GetStatus() => (int)_tag.GetStatus(); + public int GetStatus() => + (int)LibplctagLegacyStatusTranslation.ToAbStatus(_releasedStatus ?? _tag.GetStatus()); /// public object? DecodeValue(AbLegacyDataType type, int? bitIndex) => type switch diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagOrphanRelease.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagOrphanRelease.cs new file mode 100644 index 00000000..7b4ec909 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagOrphanRelease.cs @@ -0,0 +1,143 @@ +using System.Collections.Concurrent; +using System.Reflection; +using libplctag; +using libplctag.NativeImport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +/// +/// Releases the native tag a FAILED libplctag initialisation leaves behind (T21 press pass 1 crash). +/// +/// +/// libplctag.NET 1.5.2's NativeTagWrapper.InitializeAsync calls plc_tag_create_ex, which returns a +/// native handle with the wrapper's callback_func_ex delegate registered, and only then waits for the +/// Created event. When that event carries an error (a tag the controller does not have, a refused path, ...) +/// it throws WITHOUT setting _isInitialized, and Dispose() unregisters and destroys only +/// if (_isInitialized). 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 DESTROYED event from plc_tag_shutdown, which the native library runs from +/// atexit — 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. +/// The wrapper exposes no handle, so it is read by reflection from the two private fields of the pinned +/// 1.5.2 wrapper (Tag._tag, NativeTagWrapper.nativeTagHandle). 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 LibplctagLegacyOrphanReleaseTests fails, so the bump is noticed. +/// +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; + + /// Tags whose handle could not be read: kept reachable so their callback delegate is never collected. + private static readonly ConcurrentBag Rooted = []; + + private static int _released; + + /// True when the pinned wrapper's private fields were found, so a failed tag can be destroyed. + internal static bool CanReadHandles => HandleField is not null && !ForceRootingForTest; + + /// Test seam (review r1 M1): take the rooting fallback as if the wrapper's fields had been renamed. + internal static bool ForceRootingForTest { get; set; } + + /// Orphaned native tags destroyed so far (process-wide; diagnostics and tests). + internal static int Released => Volatile.Read(ref _released); + + /// Orphaned tags rooted for the process lifetime because their handle could not be read. + internal static int RootedCount => Rooted.Count; + + /// 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. + 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; + } + } + + /// Clears the wrapper's handle after a destroy, so a repeat release is a no-op (review r1 M2). + private static void ClearHandle(Tag tag) + { + if (HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper) HandleField.SetValue(wrapper, 0); + } + + /// + /// Runs a libplctag operation that may initialise (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. + /// + internal static async Task RunAsync(Tag tag, Func op) + { + try + { + await op().ConfigureAwait(false); + } + catch + { + ReleaseIfOrphaned(tag); + throw; + } + } + + /// + internal static async Task RunAsync(Tag tag, Func> op) + { + try + { + return await op().ConfigureAwait(false); + } + catch + { + ReleaseIfOrphaned(tag); + throw; + } + } + + /// + /// Destroys the native tag behind an UNINITIALISED wrapper, unregistering its callback first. No-op for an + /// initialised tag (its own Dispose handles it) or one that never got a handle. Never throws. + /// + /// The status the native tag held just before it was destroyed (what GetStatus() would have + /// reported), or null when nothing was destroyed. + 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; + } + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagPath.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagPath.cs new file mode 100644 index 00000000..a7104292 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagPath.cs @@ -0,0 +1,13 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +/// +/// Normalizes a parsed CIP route for libplctag's Path attribute. libplctag rejects an +/// empty-string path with ErrorBadData; "no route" (Micro800, unrouted MicroLogix) must be +/// expressed by omitting the attribute, i.e. . +/// +internal static class LibplctagPath +{ + /// Returns for a null, empty or whitespace path; otherwise the path unchanged. + /// The parsed CIP path. + public static string? Normalize(string? path) => string.IsNullOrWhiteSpace(path) ? null : path; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagFactory.cs new file mode 100644 index 00000000..3048d8ba --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagFactory.cs @@ -0,0 +1,14 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +/// Creates instances over a +/// (the process-wide shared pool by default). +internal sealed class NativeAbLegacyTagFactory(DeviceSessionPool? pool = null) : IAbLegacyTagFactory +{ + private readonly DeviceSessionPool _pool = pool ?? DeviceSessionPool.Shared; + + /// + public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams) => + new NativeAbLegacyTagRuntime(createParams, _pool); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs new file mode 100644 index 00000000..b11d3a82 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs @@ -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; + +/// +/// Native : PCCC data-table reads and writes carried in Execute PCCC +/// through the device's shared (one per device connection, never per tag). +/// Decoded values, exceptions and statuses match . +/// +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; + + /// Creates a runtime that leases the shared connection for its device from . + 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; + } + + /// Test seam: runs over an explicit scheduler with an explicit requestor identity. + 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]; + } + + /// + /// Routing per PLC family, from the golden captures (Captures/README.md "Routing observed"): + /// slc500 / micrologix / plc5 send Execute PCCC bare in SendRRData with no route even when a + /// 1,0 path is configured, so they are . logixpccc sends an + /// Unconnected Send (0x52) with route 01 00 (README line ~129, scratch capture: ab_server + /// rejected it, so the shape is read from the client side), so it is . + /// + /// + /// 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. + /// + 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, + }; + + /// + 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)); + + /// + public Task ReadAsync(CancellationToken cancellationToken) => RunAsync(() => ReadCoreAsync(cancellationToken)); + + /// + public Task WriteAsync(CancellationToken cancellationToken) => RunAsync(() => WriteCoreAsync(cancellationToken)); + + /// + public int GetStatus() => Volatile.Read(ref _status); + + private async Task RunAsync(Func 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> ExchangeAsync(Func 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 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 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)); + + /// The bit of a status-bit sub-element (T4:0.DN, C5:0.CU, R6:0.EN...), else null. + 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; + + /// + 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]; + } + + /// + 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."); + } + } + + /// + 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."); + } + } + + /// Releases the lease on the shared device connection (the last release closes it). + 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); + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj index 167eb613..b4fe82f3 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor index 2a422476..86bb6c7b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor @@ -32,6 +32,15 @@
Only enable when UDT member declaration order matches controller compiled layout.
+
+ + + + + + +
Which protocol stack talks to the controller. Default omits the setting so the node-level Drivers:AbCip:DefaultTransport picks (Native unless an operator pinned it); takes effect on the next deploy.
+
@@ -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).
public string GetConfigJson() - => JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts); + => Serialize(_form.ToOptions(_preservedDevices)); + + /// Serializes options with the form's JSON settings (camelCase, enums as strings). + 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(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), diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor index 58ccebb3..1a91c666 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor @@ -19,6 +19,15 @@
Default read/write timeout. Default 2 s.
+
+ + + + + + +
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.
+
@@ -101,7 +110,11 @@ /// Serializes the current channel/protocol config to camelCase JSON, preserving the driver's /// existing Devices array (authored as separate v3 entities). public string GetConfigJson() - => JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts); + => Serialize(_form.ToOptions(_preservedDevices)); + + /// Serializes options with the form's JSON settings (camelCase, enums as strings). + 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(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, }; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/AbCipDriverDefaultsOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/AbCipDriverDefaultsOptions.cs new file mode 100644 index 00000000..c9cc37d5 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/AbCipDriverDefaultsOptions.cs @@ -0,0 +1,58 @@ +using ZB.MOM.WW.Configuration; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; + +/// +/// Node-level defaults for the AbCip driver (final review I1), bound from Drivers:AbCip. Lets an +/// operator pin the transport an AbCip driver with NO transport key runs on — at upgrade time, +/// without editing (or being able to edit, #576) every driver config. An explicit per-driver +/// transport key still wins. Overridable by environment variable +/// Drivers__AbCip__DefaultTransport. +/// +public sealed class AbCipDriverDefaultsOptions +{ + /// The configuration section path. + public const string SectionName = "Drivers:AbCip"; + + /// + /// Native (the default) or Libplctag, case-insensitive. Blank/absent = Native. + /// Any other value fails host start (). + /// + public string? DefaultTransport { get; set; } = nameof(AbCipTransport.Native); + + /// + /// The parsed default: blank resolves to the compiled . + /// Throws on an unparseable value — unreachable at runtime because the validator fails host start first. + /// + /// The node-level default transport. + public AbCipTransport ResolveDefaultTransport() + { + if (string.IsNullOrWhiteSpace(DefaultTransport)) return AbCipTransportResolver.Default; + return AbCipTransportResolver.TryParse(DefaultTransport, out var t) + ? t + : throw new InvalidOperationException(AbCipDriverDefaultsOptionsValidator.Message(DefaultTransport)); + } +} + +/// Fail-fast startup validator for . +public sealed class AbCipDriverDefaultsOptionsValidator : OptionsValidatorBase +{ + /// The operator-facing failure text for a bad value. + /// The rejected value. + /// The message. + internal static string Message(string? value) => + $"Drivers:AbCip:DefaultTransport is '{value}' — expected one of " + + $"{string.Join(", ", Enum.GetNames())} (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."; + + /// + protected override void Validate(ValidationBuilder builder, AbCipDriverDefaultsOptions options) + { + builder.RequireThat( + string.IsNullOrWhiteSpace(options.DefaultTransport) + || AbCipTransportResolver.TryParse(options.DefaultTransport, out _), + Message(options.DefaultTransport)); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs index 4943dee6..dc687cf8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs @@ -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(); - 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(sp => @@ -140,8 +146,21 @@ public static class DriverFactoryBootstrap public static IServiceCollection AddOtOpcUaDriverProbes(this IServiceCollection services) { services.TryAddEnumerable(ServiceDescriptor.Singleton()); - services.TryAddEnumerable(ServiceDescriptor.Singleton()); - services.TryAddEnumerable(ServiceDescriptor.Singleton()); + // 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( + 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(sp => + { + ConfigureAbTransportLogging(sp); + return new AbLegacyProbe(); + })); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); @@ -155,6 +174,29 @@ public static class DriverFactoryBootstrap return services; } + /// + /// Gives the native AB transport's process-wide DeviceSessionPool.Shared 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 + /// leaves the pool on its null logger. + /// + /// The service provider. + internal static void ConfigureAbTransportLogging(IServiceProvider sp) + { + if (sp.GetService() is { } loggerFactory) + Driver.Ab.Transport.DeviceSessionPool.ConfigureSharedLogger(loggerFactory); + } + + /// + /// The node-level AbCip default transport (Drivers:AbCip:DefaultTransport, bound + validated in + /// Program.cs). A container without the options bound (test graphs) gets the compiled default. + /// + /// The service provider. + /// The transport an AbCip driver with no transport key runs on. + internal static Driver.AbCip.AbCipTransport ResolveAbCipDefaultTransport(IServiceProvider sp) => + sp.GetService>()?.Value.ResolveDefaultTransport() + ?? Driver.AbCip.AbCipTransportResolver.Default; + /// /// Invoke every cross-platform driver's Register extension. New driver assemblies /// get added here — one line per type. ShouldStub() in DriverInstanceActor 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); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 6d18a34e..a2723220 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -175,6 +175,13 @@ builder.Services.AddOtOpcUaCluster(builder.Configuration); // the startup check (AddOtOpcUaAuth binds LdapOptions but does not attach a validator). builder.Services.AddValidatedOptions(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( + builder.Configuration, AbCipDriverDefaultsOptions.SectionName); + if (hasDriver) { builder.Services.AddOtOpcUaRuntime(); diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/AbCipTransportOptionTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/AbCipTransportOptionTests.cs new file mode 100644 index 00000000..3e2c6b92 --- /dev/null +++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/AbCipTransportOptionTests.cs @@ -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; + +/// The CLI --transport flag reaches . +[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; + } + } + + /// + /// 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 transport key takes in a deployed config. + /// + [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); + } + + /// Verifies CliFx parses --transport (case-insensitive) into the options. + [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); + } +} diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/BuildOptionsTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/BuildOptionsTests.cs index 0b60a179..3fa11d6e 100644 --- a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/BuildOptionsTests.cs +++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/BuildOptionsTests.cs @@ -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); } + + /// + /// With no --transport 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. + /// + [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); + } + + /// + /// End to end through CliFx: no --transport resolves to libplctag for every --plc-type + /// (final review I3). The slc500 / micrologix rows are the falsification rows — they resolved Native + /// before the revert. + /// + [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); + } + + /// Verifies CliFx really parses --transport (case-insensitive) into the property. + [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; + } + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs new file mode 100644 index 00000000..8cadaa43 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs @@ -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.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().Where(s => s is not AbStatus.Ok and not AbStatus.Pending)) + ((int)s).ShouldBeLessThan(0); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFile.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFile.cs new file mode 100644 index 00000000..962db6d4 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFile.cs @@ -0,0 +1,48 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures; + +/// One EtherNet/IP encapsulation frame from a golden capture. +/// true for a client→server (>) frame. +/// The full frame: 24-byte encapsulation header plus data. +internal sealed record CapturedFrame(bool FromClient, byte[] Bytes); + +/// +/// Loads the golden libplctag ⇄ ab_server captures in Captures/*.hex. See +/// Captures/README.md for how each was taken. +/// +internal static class CaptureFile +{ + /// The directory the .hex fixtures are copied to. + public static string Dir => Path.Combine(AppContext.BaseDirectory, "Captures"); + + /// Loads every frame of a capture, in wire order; # lines are skipped. + /// The capture's file name, e.g. logix-read-dint.hex. + /// The frames. + public static IReadOnlyList 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(); + + /// The CIP payload of a SendRRData / SendUnitData frame (the last CPF item's data, + /// minus the 2-byte sequence count for connected data). + /// The frame. + /// The CIP payload, or empty for any other encapsulation command. + 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; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFileTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFileTests.cs new file mode 100644 index 00000000..eab2d89e --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFileTests.cs @@ -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 AllCaptures() + { + var data = new TheoryData(); + 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(); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/README.md b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/README.md new file mode 100644 index 00000000..d8922ddb --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/README.md @@ -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 -- 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= $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 /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. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-forward-close.hex b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-forward-close.hex new file mode 100644 index 00000000..5fedfbc4 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-forward-close.hex @@ -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 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-forward-open.hex b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-forward-open.hex new file mode 100644 index 00000000..fd300cb9 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-forward-open.hex @@ -0,0 +1,9 @@ +# logix-forward-open.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 : Large Forward Open 0x5B request/reply (frames 3-4 of the logix-read-dint session). libplctag never sent a 0x54 here: the Large FO succeeded, so no fallback occurred. +> 6F004400F3A5081A00000000EFB223110000000000000000000000000100020000000000B20034005B02200624010A0500000000609E3F07B1723DF3454350210100000040420F00A20F004240420F00A20F0042A303010020022401 +< 6F002E00F3A5081A00000000000000000000000000000000000000000100020000000000B2001E00DB0000003F63DF2C609E3F07B1723DF34543502140420F0040420F000000 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-bool.hex b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-bool.hex new file mode 100644 index 00000000..c85f4a4e --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-bool.hex @@ -0,0 +1,11 @@ +# logix-read-bool.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=TestBOOL +# session: logix-read-bool (captured 2026-10-07, scripts/ab-capture/capture.sh) +# frames : Two connected 0x52 exchanges for a BOOL (Initialize read + explicit Read). TestBOOL held 1, seeded by a raw UCMM Write Tag (client seed-bool) because ab_server rejects libplctag's own BOOL write with CIP 0x03. +> 70002800F7F16E35000000000000000000000000000000000000000001000200A10004001EEDEAA0B100140001005205910854657374424F4F4C010000000000 +< 70001D00F7F16E35000000000000000000000000000000000000000001000200A10004004DFBAD17B10009000100D2000000C10001 +> 70002800F7F16E35000000000000000000000000000000000000000001000200A10004001EEDEAA0B100140002005205910854657374424F4F4C010000000000 +< 70001D00F7F16E35000000000000000000000000000000000000000001000200A10004004DFBAD17B10009000200D2000000C10001 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-dint-array16.hex b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-dint-array16.hex new file mode 100644 index 00000000..a5efb005 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-dint-array16.hex @@ -0,0 +1,11 @@ +# logix-read-dint-array16.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=TestArray&elem_count=16 +# session: logix-read-dint-array16 (captured 2026-10-07, scripts/ab-capture/capture.sh) +# frames : Two connected 0x52 exchanges for 16 DINT elements (Initialize read + explicit Read). Seeded first (client write ... dintseq): element i (0-based) = ((i+1) << 24) | (i+1), i.e. 0x01000001, 0x02000002 ... 0x10000010. +> 70002A00F43B6DB3000000000000000000000000000000000000000001000200A1000400A07474F5B100160001005206910954657374417272617900100000000000 +< 70005C00F43B6DB3000000000000000000000000000000000000000001000200A1000400943EDA10B10048000100D2000000C4000100000102000002030000030400000405000005060000060700000708000008090000090A00000A0B00000B0C00000C0D00000D0E00000E0F00000F10000010 +> 70002A00F43B6DB3000000000000000000000000000000000000000001000200A1000400A07474F5B100160002005206910954657374417272617900100000000000 +< 70005C00F43B6DB3000000000000000000000000000000000000000001000200A1000400943EDA10B10048000200D2000000C4000100000102000002030000030400000405000005060000060700000708000008090000090A00000A0B00000B0C00000C0D00000D0E00000E0F00000F10000010 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-dint.hex b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-dint.hex new file mode 100644 index 00000000..2c9e47ab --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-dint.hex @@ -0,0 +1,11 @@ +# logix-read-dint.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 : Two connected Read Tag Fragmented (0x52) exchanges: the read Initialize performs, then the explicit Read. TestDINT held 123456. +> 70002800F3A5081A000000000000000000000000000000000000000001000200A10004003F63DF2CB10014000100520591085465737444494E54010000000000 +< 70002000F3A5081A000000000000000000000000000000000000000001000200A1000400609E3F07B1000C000100D2000000C40040E20100 +> 70002800F3A5081A000000000000000000000000000000000000000001000200A10004003F63DF2CB10014000200520591085465737444494E54010000000000 +< 70002000F3A5081A000000000000000000000000000000000000000001000200A1000400609E3F07B1000C000200D2000000C40040E20100 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-string.hex b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-string.hex new file mode 100644 index 00000000..ea4ace3b --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-string.hex @@ -0,0 +1,11 @@ +# logix-read-string.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=TestString +# session: logix-read-string (captured 2026-10-07, scripts/ab-capture/capture.sh) +# frames : Two connected 0x52 exchanges for a STRING (Initialize read + explicit Read). TestString held "HelloAB". +> 70002A00C782C518000000000000000000000000000000000000000001000200A10004003194EE20B100160001005206910A54657374537472696E67010000000000 +< 70007400C782C518000000000000000000000000000000000000000001000200A1000400C02DDF0FB10060000100D2000000D0000700000048656C6C6F41420000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +> 70002A00C782C518000000000000000000000000000000000000000001000200A10004003194EE20B100160002005206910A54657374537472696E67010000000000 +< 70007400C782C518000000000000000000000000000000000000000001000200A1000400C02DDF0FB10060000200D2000000D0000700000048656C6C6F41420000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-unknown-tag.hex b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-unknown-tag.hex new file mode 100644 index 00000000..d2163619 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-read-unknown-tag.hex @@ -0,0 +1,9 @@ +# logix-read-unknown-tag.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=NoSuchTag +# session: logix-read-unknown-tag (captured 2026-10-07, scripts/ab-capture/capture.sh) +# frames : Initialize read of a non-existent tag; ab_server (patch 0002) answers general status 0x04. libplctag raised ErrorNotFound and sent no second read. +> 70002A00ABA8A2FA000000000000000000000000000000000000000001000200A100040003AB672DB10016000100520691094E6F5375636854616700010000000000 +< 70001A00ABA8A2FA000000000000000000000000000000000000000001000200A10004003CCC685DB10006000100D2000400 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-register-session.hex b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-register-session.hex new file mode 100644 index 00000000..d1b63cfe --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-register-session.hex @@ -0,0 +1,9 @@ +# logix-register-session.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 : RegisterSession request/reply (frames 1-2 of the logix-read-dint session). +> 65000400000000000000000000000000000000000000000001000000 +< 65000400F3A5081A0000000000000000000000000000000001000000 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-tags-listing.hex b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-tags-listing.hex new file mode 100644 index 00000000..9e37d1ec --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-tags-listing.hex @@ -0,0 +1,9 @@ +# logix-tags-listing.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=@tags +# session: logix-tags-listing (captured 2026-10-07, scripts/ab-capture/capture.sh) +# frames : One Get Instance Attribute List (0x55) on the Symbol class 0x6B (served by patch 0001); the reply lists all 9 seeded tags in one transfer. +> 70002800FF0C6DCD000000000000000000000000000000000000000001000200A10004006CA8729AB100140001005503206B2500000004000200070008000100 +< 70003301FF0C6DCD000000000000000000000000000000000000000001000200A10004004E31C665B1001F010100D500000001000000C120010001000000000000000000000009004D6F746F725F52756E02000000C42004000100000000000000000000000B004C696E65315F436F756E7403000000CA2004000100000000000000000000000C0050756D705F315F537065656404000000C4200400100000000000000000000000090054657374417272617905000000D02058000100000000000000000000000A0054657374537472696E6706000000C220010001000000000000000000000008005465737453494E5407000000C1200100010000000000000000000000080054657374424F4F4C08000000CA2004000100000000000000000000000800546573745245414C09000000C420040001000000000000000000000008005465737444494E54 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-write-dint.hex b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-write-dint.hex new file mode 100644 index 00000000..ede25056 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-write-dint.hex @@ -0,0 +1,11 @@ +# logix-write-dint.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-write-dint (captured 2026-10-07, scripts/ab-capture/capture.sh) +# frames : Initialize read (0x52) then Write Tag (0x4D) of 123456. +> 70002800B517D7FE000000000000000000000000000000000000000001000200A10004000B9D929FB10014000100520591085465737444494E54010000000000 +< 70002000B517D7FE000000000000000000000000000000000000000001000200A1000400390C6A4CB1000C000100D2000000C40000000000 +> 70002A00B517D7FE000000000000000000000000000000000000000001000200A10004000B9D929FB100160002004D0591085465737444494E54C400010040E20100 +< 70001A00B517D7FE000000000000000000000000000000000000000001000200A1000400390C6A4CB10006000200CD000000 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-write-string.hex b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-write-string.hex new file mode 100644 index 00000000..e4bf2fd7 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/logix-write-string.hex @@ -0,0 +1,11 @@ +# logix-write-string.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=TestString +# session: logix-write-string (captured 2026-10-07, scripts/ab-capture/capture.sh) +# frames : Initialize read (0x52) then Write Tag (0x4D) of the STRING "HelloAB". +> 70002A003D5D525D000000000000000000000000000000000000000001000200A10004008DE304F6B100160001005206910A54657374537472696E67010000000000 +< 700074003D5D525D000000000000000000000000000000000000000001000200A1000400D01BC454B10060000100D2000000D00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +> 700080003D5D525D000000000000000000000000000000000000000001000200A10004008DE304F6B1006C0002004D06910A54657374537472696E67D00001000700000048656C6C6F41420000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +< 70001A003D5D525D000000000000000000000000000000000000000001000200A1000400D01BC454B10006000200CD000000 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/micro800-read-dint.hex b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/micro800-read-dint.hex new file mode 100644 index 00000000..bf4710c2 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/micro800-read-dint.hex @@ -0,0 +1,17 @@ +# micro800-read-dint.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: micro800 +# tag : protocol=ab_eip&gateway=10.100.0.35:44818&plc=micro800&name=TestDINT +# session: micro800-read-dint (captured 2026-10-07, scripts/ab-capture/capture.sh) +# frames : FULL session. NOTE: libplctag 2.6.0 does NOT use UCMM for Micro800 - it opens a Large Forward Open (connection path 20 02 24 01, no port segment) and reads connected. TestDINT held 987654. +> 65000400000000000000000000000000000000000000000001000000 +< 65000400D4389A440000000000000000000000000000000001000000 +> 6F004200D4389A4400000000034C674E0000000000000000000000000100020000000000B20032005B02200624010A0500000000D717A44145043DF3454350210100000040420F00A20F004240420F00A20F0042A30220022401 +< 6F002E00D4389A4400000000000000000000000000000000000000000100020000000000B2001E00DB0000000E9EDB9ED717A44145043DF34543502140420F0040420F000000 +> 70002800D4389A44000000000000000000000000000000000000000001000200A10004000E9EDB9EB10014000100520591085465737444494E54010000000000 +< 70002000D4389A44000000000000000000000000000000000000000001000200A1000400D717A441B1000C000100D2000000C40006120F00 +> 70002800D4389A44000000000000000000000000000000000000000001000200A10004000E9EDB9EB10014000200520591085465737444494E54010000000000 +< 70002000D4389A44000000000000000000000000000000000000000001000200A1000400D717A441B1000C000200D2000000C40006120F00 +> 6F002600D4389A4400000000044C674E0000000000000000000000000100020000000000B20016004E02200624010A0545043DF345435021020020022401 +< 6F001E00D4389A4400000000000000000000000000000000000000000100020000000000B2000E00CE00000045043DF3454350210000 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/micrologix-read-n7.hex b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/micrologix-read-n7.hex new file mode 100644 index 00000000..e3897199 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/micrologix-read-n7.hex @@ -0,0 +1,13 @@ +# micrologix-read-n7.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: micrologix (host :44820) +# tag : protocol=ab_eip&gateway=10.100.0.35:44820&plc=micrologix&name=N7:0 +# session: micrologix-read-n7 (captured 2026-10-07, scripts/ab-capture/capture.sh) +# frames : FULL session (no path). PCCC 0x0F/0xA2 read, twice (Initialize + Read). N7:0 held 2468. +> 65000400000000000000000000000000000000000000000001000000 +< 65000400999FDCC20000000000000000000000000000000001000000 +> 6F002700999FDCC2000000001F7690460000000000000000000000000100020000000000B20017004B0220672401073DF3454350210F001D76A20207890000 +< 6F002100999FDCC200000000000000000000000000000000000000000100020000000000B2001100CB000000073DF3454350214F001D76A409 +> 6F002700999FDCC200000000217690460000000000000000000000000100020000000000B20017004B0220672401073DF3454350210F001F76A20207890000 +< 6F002100999FDCC200000000000000000000000000000000000000000100020000000000B2001100CB000000073DF3454350214F001F76A409 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/plc5-read-n7.hex b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/plc5-read-n7.hex new file mode 100644 index 00000000..54c6c1e5 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/plc5-read-n7.hex @@ -0,0 +1,13 @@ +# plc5-read-n7.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: plc5 (host :44821) +# tag : protocol=ab_eip&gateway=10.100.0.35:44821&path=1,0&plc=plc5&name=N7:0 +# session: plc5-read-n7 (captured 2026-10-07, scripts/ab-capture/capture.sh) +# frames : FULL session. PCCC 0x0F/0x01 PLC-5 typed read, twice (Initialize + Read). N7:0 held 1357. +> 65000400000000000000000000000000000000000000000001000000 +< 650004008543D8A50000000000000000000000000000000001000000 +> 6F002A008543D8A50000000075C4F9370000000000000000000000000100020000000000B2001A004B0220672401073DF3454350210F0073C4010000010006070002 +< 6F0021008543D8A500000000000000000000000000000000000000000100020000000000B2001100CB000000073DF3454350214F0073C44D05 +> 6F002A008543D8A50000000077C4F9370000000000000000000000000100020000000000B2001A004B0220672401073DF3454350210F0075C4010000010006070002 +< 6F0021008543D8A500000000000000000000000000000000000000000100020000000000B2001100CB000000073DF3454350214F0075C44D05 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/slc500-read-f8.hex b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/slc500-read-f8.hex new file mode 100644 index 00000000..dde9799c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/slc500-read-f8.hex @@ -0,0 +1,13 @@ +# slc500-read-f8.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: slc500 (host :44819) +# tag : protocol=ab_eip&gateway=10.100.0.35:44819&path=1,0&plc=slc500&name=F8:0 +# session: slc500-read-f8 (captured 2026-10-07, scripts/ab-capture/capture.sh) +# frames : FULL session. PCCC 0x0F/0xA2 read of F8:0, twice (Initialize + Read). F8:0 held 3.5. +> 65000400000000000000000000000000000000000000000001000000 +< 650004002E82A6A40000000000000000000000000000000001000000 +> 6F0027002E82A6A400000000A1DFE9520000000000000000000000000100020000000000B20017004B0220672401073DF3454350210F009FDFA204088A0000 +< 6F0023002E82A6A400000000000000000000000000000000000000000100020000000000B2001300CB000000073DF3454350214F009FDF00006040 +> 6F0027002E82A6A400000000A3DFE9520000000000000000000000000100020000000000B20017004B0220672401073DF3454350210F00A1DFA204088A0000 +< 6F0023002E82A6A400000000000000000000000000000000000000000100020000000000B2001300CB000000073DF3454350214F00A1DF00006040 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/slc500-read-n7.hex b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/slc500-read-n7.hex new file mode 100644 index 00000000..adc3f8cf --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/slc500-read-n7.hex @@ -0,0 +1,13 @@ +# slc500-read-n7.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: slc500 (host :44819) +# tag : protocol=ab_eip&gateway=10.100.0.35:44819&path=1,0&plc=slc500&name=N7:0 +# session: slc500-read-n7 (captured 2026-10-07, scripts/ab-capture/capture.sh) +# frames : FULL session. Unconnected Execute PCCC (0x4B) to class 0x67 inst 1, PCCC 0x0F/0xA2 protected typed logical read, twice (Initialize + Read). N7:0 held 4321. +> 65000400000000000000000000000000000000000000000001000000 +< 65000400D0410F270000000000000000000000000000000001000000 +> 6F002700D0410F27000000009B87395A0000000000000000000000000100020000000000B20017004B0220672401073DF3454350210F009987A20207890000 +< 6F002100D0410F2700000000000000000000000000000000000000000100020000000000B2001100CB000000073DF3454350214F009987E110 +> 6F002700D0410F27000000009D87395A0000000000000000000000000100020000000000B20017004B0220672401073DF3454350210F009B87A20207890000 +< 6F002100D0410F2700000000000000000000000000000000000000000100020000000000B2001100CB000000073DF3454350214F009B87E110 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/slc500-write-n7.hex b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/slc500-write-n7.hex new file mode 100644 index 00000000..59c0d663 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/slc500-write-n7.hex @@ -0,0 +1,13 @@ +# slc500-write-n7.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: slc500 (host :44819) +# tag : protocol=ab_eip&gateway=10.100.0.35:44819&path=1,0&plc=slc500&name=N7:0 +# session: slc500-write-n7 (captured 2026-10-07, scripts/ab-capture/capture.sh) +# frames : FULL session. Initialize read (PCCC 0x0F/0xA2) then write (PCCC 0x0F/0xAA) of 4321. +> 65000400000000000000000000000000000000000000000001000000 +< 65000400E65307EA0000000000000000000000000000000001000000 +> 6F002700E65307EA00000000E7A3957B0000000000000000000000000100020000000000B20017004B0220672401073DF3454350210F00E5A3A20207890000 +< 6F002100E65307EA00000000000000000000000000000000000000000100020000000000B2001100CB000000073DF3454350214F00E5A30000 +> 6F002900E65307EA00000000E9A3957B0000000000000000000000000100020000000000B20019004B0220672401073DF3454350210F00E7A3AA0207890000E110 +< 6F001F00E65307EA00000000000000000000000000000000000000000100020000000000B2000F00CB000000073DF3454350214F00E7A3 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs new file mode 100644 index 00000000..e76e9c98 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs @@ -0,0 +1,414 @@ +using System.Buffers.Binary; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Cip; + +/// Serializes every test class that asserts on 's process-wide +/// diagnostic counters, so no parallel test can move them mid-assertion. +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class CipConnectionStatics +{ + public const string Name = "CipConnection statics"; +} + +/// against . Wire I/O is real loopback; +/// the throttle, deadlines and keep-alive run on . +[Collection(CipConnectionStatics.Name)] +public sealed class CipConnectionTests +{ + private static readonly TimeSpan Guard = TimeSpan.FromSeconds(10); + private static readonly byte[] ReadTag = [0x4C, 0x02, 0x91, 0x03, 0x54, 0x61, 0x67, 0x00, 0x01, 0x00]; + private static CancellationToken TestCt => TestContext.Current.CancellationToken; + + private static CipConnection Create(FakeCipTarget target, ConnectionOptions o, ManualTimeProvider time, string path = "1,0") => + new(new DeviceKey("127.0.0.1", target.Port, path, "ControlLogix"), o, + new ForwardOpenThrottle(o.MinForwardOpenInterval, time), time); + + private static ConnectionOptions Options(CipRouting routing = CipRouting.Connected, int size = 4002, double timeoutSeconds = 2) => + new() { Routing = routing, ConnectionSize = size, Timeout = TimeSpan.FromSeconds(timeoutSeconds) }; + + private static ushort RegularOTParams(byte[] fo) => BinaryPrimitives.ReadUInt16LittleEndian(fo.AsSpan(32)); + + // ---- options --------------------------------------------------------------------------------- + + [Fact] + public void Defaults_match_the_press_safety_values() + { + var o = new ConnectionOptions(); + o.MinForwardOpenInterval.ShouldBe(TimeSpan.FromSeconds(1)); + o.RpiMicros.ShouldBe(2_000_000u); + o.TimeoutMultiplier.ShouldBe((byte)1); + o.ConnectionExpiry.ShouldBe(TimeSpan.FromSeconds(16)); + o.KeepAliveInterval.ShouldBe(TimeSpan.FromSeconds(8)); + o.VendorId.ShouldBe((ushort)0x1337); + new ConnectionOptions().OriginatorSerial.ShouldBe(o.OriginatorSerial); // per process, not per instance + } + + public static TheoryData UnusableTimeouts => new() { Timeout.InfiniteTimeSpan, TimeSpan.Zero, TimeSpan.FromSeconds(-1) }; + + [Theory] + [MemberData(nameof(UnusableTimeouts))] + public void Options_reject_a_non_finite_or_non_positive_timeout(TimeSpan timeout) => + Should.Throw(() => new ConnectionOptions { Timeout = timeout }); + + [Fact] + public void Options_reject_a_forward_open_interval_below_one_second() => + Should.Throw(() => new ConnectionOptions { MinForwardOpenInterval = TimeSpan.FromMilliseconds(999) }); + + // ---- Large Forward Open fallback ------------------------------------------------------------- + + [Fact] + public async Task Large_open_service_not_supported_falls_back_to_regular_open_once() + { + await using var target = new FakeCipTarget(); + target.ForwardOpenReply = fo => fo[0] == 0x5B ? FakeCipTarget.Refuse(0x08)(fo) : FakeCipTarget.Accept(fo); + var time = new ManualTimeProvider(); + await using var conn = Create(target, Options(), time); + var before = Volatile.Read(ref CipConnection.ForwardOpenCount); + + await conn.SendAsync(ReadTag, TestCt); + + var fos = target.ForwardOpenRequests; + fos.Count.ShouldBe(2); + fos[0][0].ShouldBe((byte)0x5B); + fos[1][0].ShouldBe((byte)0x54); + RegularOTParams(fos[1]).ShouldBe((ushort)(0x4200 | 504)); + conn.MaxRequestBytes.ShouldBe(502); + (Volatile.Read(ref CipConnection.ForwardOpenCount) - before).ShouldBe(2); + target.ConnectedRequests.ShouldBe(1); + } + + [Fact] + public async Task Large_open_bad_size_falls_back_to_regular_open_once() + { + await using var target = new FakeCipTarget(); + target.ForwardOpenReply = fo => fo[0] == 0x5B ? FakeCipTarget.Refuse(0x01, 0x0109)(fo) : FakeCipTarget.Accept(fo); + var time = new ManualTimeProvider(); + await using var conn = Create(target, Options(), time); + + await conn.SendAsync(ReadTag, TestCt); + + target.ForwardOpenRequests.Select(f => f[0]).ShouldBe(new byte[] { 0x5B, 0x54 }); + RegularOTParams(target.ForwardOpenRequests[1]).ShouldBe((ushort)(0x4200 | 504)); + } + + [Fact] + public async Task A_refused_fallback_is_not_retried_again() + { + await using var target = new FakeCipTarget { ForwardOpenReply = FakeCipTarget.Refuse(0x08) }; + var time = new ManualTimeProvider(); + await using var conn = Create(target, Options(), time); + + await Should.ThrowAsync(() => conn.SendAsync(ReadTag, TestCt)); + + target.ForwardOpens.ShouldBe(2); // Large, then exactly one regular + } + + [Fact] + public async Task A_spent_open_attempt_budget_refuses_the_reconnect_before_any_io() + { + // Task 18 review r1 (C1): the differential harness caps the process at one open attempt, so a press + // that refuses the Forward Open sees one TCP connect and one Forward Open, never one per remaining tag. + await using var target = new FakeCipTarget { ForwardOpenReply = FakeCipTarget.Refuse(0x01, 0x0100) }; + var time = new ManualTimeProvider(); + await using var conn = Create(target, Options(size: 504), time); + var before = Volatile.Read(ref CipConnection.OpenAttempts); + Volatile.Write(ref CipConnection.OpenAttemptBudget, before + 1); + try + { + await Should.ThrowAsync(() => conn.SendAsync(ReadTag, TestCt)); + var retry = conn.SendAsync(ReadTag, TestCt); + await time.WaitForTimerDueAtAsync(time.Start + TimeSpan.FromSeconds(1), Guard); // parked in the throttle + time.Advance(TimeSpan.FromSeconds(1)); + var refused = await Should.ThrowAsync(() => retry.WaitAsync(Guard, TestCt)); + + refused.Status.ShouldBe(AbStatus.Abort); + refused.Message.ShouldContain("budget"); + target.Server.Connections.ShouldBe(1); + target.ForwardOpens.ShouldBe(1); + } + finally + { + Volatile.Write(ref CipConnection.OpenAttemptBudget, -1); + } + } + + [Fact] + public async Task Connection_in_use_does_not_fall_back() + { + await using var target = new FakeCipTarget { ForwardOpenReply = FakeCipTarget.Refuse(0x01, 0x0100) }; + var time = new ManualTimeProvider(); + await using var conn = Create(target, Options(), time); + + var ex = await Should.ThrowAsync(() => conn.SendAsync(ReadTag, TestCt)); + + ex.Message.ShouldContain("Forward Open refused"); + target.ForwardOpens.ShouldBe(1); + target.ConnectedRequests.ShouldBe(0); + } + + [Fact] + public async Task A_small_connection_size_uses_a_regular_open() + { + await using var target = new FakeCipTarget(); + var time = new ManualTimeProvider(); + await using var conn = Create(target, Options(size: 504), time); + + await conn.SendAsync(ReadTag, TestCt); + + target.ForwardOpenRequests.Single()[0].ShouldBe((byte)0x54); + conn.MaxRequestBytes.ShouldBe(502); + } + + // ---- routing (B3) ---------------------------------------------------------------------------- + + [Fact] + public async Task Direct_routing_sends_bare_cip_even_with_a_path() + { + await using var target = new FakeCipTarget(); + var time = new ManualTimeProvider(); + await using var conn = Create(target, Options(CipRouting.Direct), time, path: "1,0"); + + var reply = await conn.SendAsync(ReadTag, TestCt); + + CipReply.Parse(reply).GeneralStatus.ShouldBe((byte)0); + target.DirectCips.Single().ShouldBe(ReadTag); + target.ForwardOpens.ShouldBe(0); + target.UnconnectedSends.ShouldBe(0); + target.ConnectedRequests.ShouldBe(0); + conn.MaxRequestBytes.ShouldBe(504); + } + + [Fact] + public async Task UnconnectedSend_routing_wraps_in_0x52() + { + await using var target = new FakeCipTarget(); + var time = new ManualTimeProvider(); + await using var conn = Create(target, Options(CipRouting.UnconnectedSend), time, path: "1,0"); + + await conn.SendAsync(ReadTag, TestCt); + + target.UnconnectedEmbedded.Single().ShouldBe(ReadTag); + var raw = FakeEipServer.RequestCip(target.Server.Received.Last(f => FakeEipServer.Command(f) == 0x006F)); + raw.ShouldBe(ConnectionManagerServices.UnconnectedSend(ReadTag, new byte[] { 0x01, 0x00 })); + target.ForwardOpens.ShouldBe(0); + target.DirectRequests.ShouldBe(0); + conn.MaxRequestBytes.ShouldBe(504 - (12 + 2)); + } + + [Fact] + public async Task Connected_routing_never_sends_bare() + { + await using var target = new FakeCipTarget(); + var time = new ManualTimeProvider(); + await using var conn = Create(target, Options(), time); + + for (var i = 0; i < 3; i++) await conn.SendAsync(ReadTag, TestCt); + + target.ConnectedCips.Count.ShouldBe(3); + target.ConnectedCips.ShouldAllBe(c => c.SequenceEqual(ReadTag)); + target.ForwardOpens.ShouldBe(1); + target.DirectRequests.ShouldBe(0); + target.UnconnectedSends.ShouldBe(0); + conn.MaxRequestBytes.ShouldBe(4000); + } + + [Fact] + public async Task Connected_routing_with_an_empty_route_opens_straight_to_the_message_router() + { + // R9-1: Micro800 is Connected with an empty route; libplctag's Forward Open path is just 20 02 24 01. + await using var target = new FakeCipTarget(); + var time = new ManualTimeProvider(); + await using var conn = Create(target, Options(size: 504), time, path: ""); + + await conn.SendAsync(ReadTag, TestCt); + + var fo = target.ForwardOpenRequests.Single(); + fo[^6..].ShouldBe(new byte[] { 0xA3, 0x02, 0x20, 0x02, 0x24, 0x01 }); + target.ConnectedRequests.ShouldBe(1); + } + + // ---- reconnect ------------------------------------------------------------------------------- + + [Fact] + public async Task Caller_cancellation_does_not_reconnect() + { + await using var target = new FakeCipTarget(); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var held = 0; + target.ConnectedHandler = async cip => + { + if (Interlocked.Increment(ref held) == 1) await release.Task; + return [(byte)(cip[0] | 0x80), 0, 0, 0]; + }; + var time = new ManualTimeProvider(); + await using var conn = Create(target, Options(), time); + var before = Volatile.Read(ref CipConnection.ForwardOpenCount); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestCt); + var pending = conn.SendAsync(ReadTag, cts.Token); + await FakeCipTarget.Until(() => target.ConnectedRequests == 1, Guard, "the connected request"); + await cts.CancelAsync(); + await Should.ThrowAsync(() => pending); + release.SetResult(); + + await conn.SendAsync(ReadTag, TestCt); + + (Volatile.Read(ref CipConnection.ForwardOpenCount) - before).ShouldBe(1); + target.ForwardOpens.ShouldBe(1); + target.Server.Connections.ShouldBe(1); + target.ConnectedRequests.ShouldBe(2); + } + + [Fact] + public async Task Timeout_reconnects_through_the_throttle() + { + await using var target = new FakeCipTarget(); + var answer = 1; + target.ConnectedHandler = cip => Task.FromResult( + Volatile.Read(ref answer) == 1 ? [(byte)(cip[0] | 0x80), 0, 0, 0] : null); + var time = new ManualTimeProvider(); + await using var conn = Create(target, Options(timeoutSeconds: 0.3), time); + + await conn.SendAsync(ReadTag, TestCt); // first Forward Open, at fake t0 + Volatile.Write(ref answer, 0); + var timedOut = await Should.ThrowAsync(() => conn.SendAsync(ReadTag, TestCt)); + timedOut.Status.ShouldBe(AbStatus.Timeout); // the failing call keeps its own error + target.ForwardOpens.ShouldBe(1); + + Volatile.Write(ref answer, 1); + var reconnect = conn.SendAsync(ReadTag, TestCt); + await time.WaitForTimerDueAtAsync(time.Start + TimeSpan.FromSeconds(1), Guard); // parked in the throttle + target.ForwardOpens.ShouldBe(1); + reconnect.IsCompleted.ShouldBeFalse(); + + time.Advance(TimeSpan.FromSeconds(1)); + await reconnect.WaitAsync(Guard, TestCt); + + target.ForwardOpens.ShouldBe(2); + target.Server.Connections.ShouldBe(2); + // A fresh connection serial per attempt, so the target never sees a duplicate of the stale connection. + var serials = target.ForwardOpenRequests.Select(f => BinaryPrimitives.ReadUInt16LittleEndian(f.AsSpan(16))).ToArray(); + serials[1].ShouldNotBe(serials[0]); + } + + [Fact] + public async Task A_stalled_open_is_bounded_by_the_deadline() + { + // R9-3: no caller token at all, and the target never answers the Forward Open. + await using var target = new FakeCipTarget(); + var time = new ManualTimeProvider(); + await using var conn = Create(target, Options(timeoutSeconds: 30), time); + target.Server.Stall = true; + + var send = conn.SendAsync(ReadTag, CancellationToken.None); + await FakeCipTarget.Until(() => target.ForwardOpens == 0 && target.Server.Received.Count >= 2, Guard, "the Forward Open frame"); + var openBudget = TimeSpan.FromSeconds(1) + 4 * TimeSpan.FromSeconds(30); + await time.WaitForTimerDueAtAsync(time.Start + openBudget, Guard); + time.Advance(openBudget); + + var ex = await Should.ThrowAsync(() => send.WaitAsync(Guard, TestCt)); + ex.Status.ShouldBe(AbStatus.Timeout); + } + + // ---- keep-alive ------------------------------------------------------------------------------ + + [Fact] + public async Task Idle_connection_sends_one_keepalive_per_interval() + { + await using var target = new FakeCipTarget(); + var time = new ManualTimeProvider(); + await using var conn = Create(target, Options(), time); + var before = Volatile.Read(ref CipConnection.RequestsSent); + await conn.SendAsync(ReadTag, TestCt); + + time.Advance(TimeSpan.FromSeconds(8)); + conn.KeepAlivesSent.ShouldBe(1); + await FakeCipTarget.Until(() => target.ConnectedRequests == 2, Guard, "the keep-alive"); + target.ConnectedCips[1].ShouldBe(CipConnection.KeepAliveRequest); + await time.WaitForTimerDueAtAsync(time.Start + TimeSpan.FromSeconds(16), Guard); // re-armed + + time.Advance(TimeSpan.FromSeconds(7)); + conn.KeepAlivesSent.ShouldBe(1); // exactly one per idle interval + + time.Advance(TimeSpan.FromSeconds(1)); + conn.KeepAlivesSent.ShouldBe(2); + await FakeCipTarget.Until(() => target.ConnectedRequests == 3, Guard, "the second keep-alive"); + (Volatile.Read(ref CipConnection.RequestsSent) - before).ShouldBe(3); // keep-alives count + target.ForwardOpens.ShouldBe(1); + } + + [Fact] + public async Task Traffic_suppresses_keepalive() + { + await using var target = new FakeCipTarget(); + var time = new ManualTimeProvider(); + await using var conn = Create(target, Options(), time); + await conn.SendAsync(ReadTag, TestCt); + + for (var i = 0; i < 4; i++) + { + time.Advance(TimeSpan.FromSeconds(5)); + await conn.SendAsync(ReadTag, TestCt); + } + + conn.KeepAlivesSent.ShouldBe(0); + target.ConnectedCips.ShouldAllBe(c => c.SequenceEqual(ReadTag)); + target.ConnectedRequests.ShouldBe(5); + } + + [Fact] + public async Task No_keepalive_is_sent_when_the_connection_was_never_opened() + { + await using var target = new FakeCipTarget(); + var time = new ManualTimeProvider(); + await using var conn = Create(target, Options(), time); + + time.Advance(TimeSpan.FromSeconds(30)); + + conn.KeepAlivesSent.ShouldBe(0); + target.ForwardOpens.ShouldBe(0); + target.Server.Connections.ShouldBe(0); + } + + // ---- disposal -------------------------------------------------------------------------------- + + [Fact] + public async Task Send_after_dispose_throws_Abort() + { + await using var target = new FakeCipTarget(); + var time = new ManualTimeProvider(); + var conn = Create(target, Options(), time); + await conn.SendAsync(ReadTag, TestCt); + + await conn.DisposeAsync(); + + var ex = await Should.ThrowAsync(() => conn.SendAsync(ReadTag, TestCt)); + ex.Status.ShouldBe(AbStatus.Abort); + target.ForwardCloses.ShouldBe(1); + await conn.DisposeAsync(); // idempotent + } + + [Fact] + public async Task Callers_queued_behind_an_open_get_Abort_when_disposed() + { + await using var target = new FakeCipTarget { ForwardOpenReply = FakeCipTarget.Refuse(0x01, 0x0113) }; + var time = new ManualTimeProvider(); + var conn = Create(target, Options(), time); + await Should.ThrowAsync(() => conn.SendAsync(ReadTag, TestCt)); // attempt 1, t0 + + var parked = conn.SendAsync(ReadTag, TestCt); // waits in the throttle + await time.WaitForTimerDueAtAsync(time.Start + TimeSpan.FromSeconds(1), Guard); + var queued = Enumerable.Range(0, 5).Select(_ => conn.SendAsync(ReadTag, TestCt)).ToArray(); + var dispose = conn.DisposeAsync().AsTask(); + time.Advance(TimeSpan.FromSeconds(1)); + + (await Should.ThrowAsync(() => parked.WaitAsync(Guard, TestCt))).Status.ShouldBe(AbStatus.Abort); + foreach (var q in queued) + (await Should.ThrowAsync(() => q.WaitAsync(Guard, TestCt))).Status.ShouldBe(AbStatus.Abort); + await dispose.WaitAsync(Guard, TestCt); + target.ForwardOpens.ShouldBe(1); // nothing is opened once disposal has begun + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipPathTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipPathTests.cs new file mode 100644 index 00000000..8b3b5415 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipPathTests.cs @@ -0,0 +1,55 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Cip; + +public sealed class CipPathTests +{ + [Theory] + [InlineData("1,0", "0100")] + [InlineData("", "")] + [InlineData("1,0,2,10.0.0.5", "0100" + "1208" + "31302E302E302E35")] + [InlineData("2,10.0.0.55", "1209" + "31302E302E302E3535" + "00")] + [InlineData("15,3", "0F0F0003")] + [InlineData("16,10.0.0.5", "1F08" + "1000" + "31302E302E302E35")] + public void Encodes_route(string path, string hex) => + Convert.ToHexString(CipPath.EncodeRoute(path)).ShouldBe(hex); + + [Fact] + public void Odd_route_parts_throw() => + Should.Throw(() => CipPath.EncodeRoute("1,0,2")); + + [Theory] + [InlineData("TestDINT", "9108546573744449 4E54")] + [InlineData("Abc", "910341626300")] + [InlineData("Program:Main.Arr[3].Val", "910C50726F6772616D3A4D61696E" + "910341727200" + "2803" + "910356616C00")] + [InlineData("Big[300]", "910342696700" + "29002C01")] + [InlineData("Huge[70000]", "910448756765" + "2A00" + "70110100")] + [InlineData("Grid[1,2]", "910447726964" + "2801" + "2802")] + public void Encodes_symbolic(string name, string hex) => + Convert.ToHexString(CipPath.EncodeSymbolic(name)).ShouldBe(hex.Replace(" ", "")); + + [Fact] + public void Symbolic_rejects_bit_selector() => + Should.Throw(() => CipPath.EncodeSymbolic("Tag.5")); + + [Fact] + public void Splits_bit_selector() + { + CipPath.SplitBitSelector("Tag.5").ShouldBe(("Tag", 5)); + CipPath.SplitBitSelector("Tag.Member").ShouldBe(("Tag.Member", null)); + CipPath.SplitBitSelector("Tag").ShouldBe(("Tag", null)); + } + + [Theory] + [InlineData(2, 1u, "20022401")] + [InlineData(0x6B, 0x1234u, "206B25003412")] + [InlineData(0x300, 1u, "21000003" + "2401")] + [InlineData(2, 70000u, "2002" + "2600" + "70110100")] + public void Encodes_class_instance(ushort cls, uint inst, string hex) => + Convert.ToHexString(CipPath.EncodeClassInstance(cls, inst)).ShouldBe(hex); + + [Fact] + public void Prefixes_path_size_in_words() => + Convert.ToHexString(CipPath.WithPathSize([0x20, 0x02, 0x24, 0x01])).ShouldBe("0220022401"); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ConnectionManagerServicesTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ConnectionManagerServicesTests.cs new file mode 100644 index 00000000..b8dcd1bf --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ConnectionManagerServicesTests.cs @@ -0,0 +1,126 @@ +using System.Buffers.Binary; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Cip; + +// CAPTURE DECISION: the brief gave timeout ticks 0x0E. Every libplctag capture (logix-forward-open, +// logix-forward-close) carries priority/tick 0x0A, timeout ticks 0x05, so ConnectionManagerServices +// uses 0x05 (brief: 0x0E -> capture: 0x05). The golden-byte tests below use 0x05 accordingly. +public sealed class ConnectionManagerServicesTests +{ + private static byte[] CapturedRequest(string file) => + CaptureFile.CipPayload(CaptureFile.Load(file).First(f => f.FromClient)); + + private static CipReply CapturedReply(string file) => + CipReply.Parse(CaptureFile.CipPayload(CaptureFile.Load(file).First(f => !f.FromClient))); + + [Fact] + public void Forward_close_frame() + { + var bytes = ConnectionManagerServices.ForwardClose(0x1234, 0x1337, 0xCAFEBABE, [0x01, 0x00]); + Convert.ToHexString(bytes).ShouldBe("4E02200624010A05" + "3412" + "3713" + "BEBAFECA" + "03" + "00" + "0100" + "20022401"); + } + + [Fact] + public void Forward_close_matches_capture() + { + var cap = CapturedRequest("logix-forward-close.hex"); + var serial = BinaryPrimitives.ReadUInt16LittleEndian(cap.AsSpan(8)); + var vendor = BinaryPrimitives.ReadUInt16LittleEndian(cap.AsSpan(10)); + var orig = BinaryPrimitives.ReadUInt32LittleEndian(cap.AsSpan(12)); + vendor.ShouldBe((ushort)0xF33D); + ConnectionManagerServices.ForwardClose(serial, vendor, orig, [0x01, 0x00]).ShouldBe(cap); + } + + [Fact] + public void Large_forward_open_matches_capture() + { + var cap = CapturedRequest("logix-forward-open.hex"); + // Values read out of the capture: T->O id @12, serial @16, vendor @18, originator serial @20, + // timeout multiplier @24, RPI @28, network params @32 (0x42000FA2 -> size 4002). + var p = new ForwardOpenParams( + Large: true, TOConnectionId: BinaryPrimitives.ReadUInt32LittleEndian(cap.AsSpan(12)), ConnectionSerial: BinaryPrimitives.ReadUInt16LittleEndian(cap.AsSpan(16)), + VendorId: BinaryPrimitives.ReadUInt16LittleEndian(cap.AsSpan(18)), OriginatorSerial: BinaryPrimitives.ReadUInt32LittleEndian(cap.AsSpan(20)), + TimeoutMultiplier: cap[24], RpiMicros: BinaryPrimitives.ReadUInt32LittleEndian(cap.AsSpan(28)), ConnectionSize: 4002, Route: [0x01, 0x00]); + p.RpiMicros.ShouldBe(1_000_000u); + BinaryPrimitives.ReadUInt32LittleEndian(cap.AsSpan(32)).ShouldBe(0x42000FA2u); + ConnectionManagerServices.ForwardOpen(p).ShouldBe(cap); + } + + [Fact] + public void Large_forward_open_reply_parses_from_capture() + { + var reply = ConnectionManagerServices.ParseForwardOpenReply(CapturedReply("logix-forward-open.hex")); + reply.OTConnectionId.ShouldBe(0x2CDF633Fu); + reply.TOConnectionId.ShouldBe(0x073F9E60u); + reply.OTApiMicros.ShouldBe(1_000_000u); + reply.TOApiMicros.ShouldBe(1_000_000u); + } + + // No small Forward Open 0x54 was captured (the Large FO succeeded), so this layout is checked against + // the CIP spec / the brief rather than a capture. + [Fact] + public void Regular_forward_open_layout_per_spec() + { + var bytes = ConnectionManagerServices.ForwardOpen(new ForwardOpenParams( + false, 0x11223344, 0x5566, 0x7788, 0x99AABBCC, 0x01, 1_000_000, 504, [0x01, 0x00])); + Convert.ToHexString(bytes).ShouldBe( + "540220062401" + "0A05" + "00000000" + "44332211" + "6655" + "8877" + "CCBBAA99" + "01000000" + + "40420F00" + "F843" + "40420F00" + "F843" + "A3" + "03" + "0100" + "20022401"); + } + + [Fact] + public void Regular_forward_open_network_params_encoding() + { + var bytes = ConnectionManagerServices.ForwardOpen(new ForwardOpenParams(false, 1, 2, 3, 4, 1, 1_000_000, 504, [])); + // 504 = 0x1F8; 0x4200 | 0x1F8 = 0x43F8 -> LE F8 43 + Convert.ToHexString(bytes).ShouldContain("40420F00" + "F843"); + bytes[0].ShouldBe((byte)0x54); + } + + [Fact] + public void Unconnected_send_even_embedded() + { + byte[] embedded = [0x4C, 0x02, 0x91, 0x01, 0x41, 0x00, 0x01, 0x00]; // 8 bytes + Convert.ToHexString(ConnectionManagerServices.UnconnectedSend(embedded, [0x01, 0x00])).ShouldBe( + "520220062401" + "0A05" + "0800" + Convert.ToHexString(embedded) + "01" + "00" + "0100"); + } + + [Fact] + public void Unconnected_send_pads_odd_embedded() + { + byte[] embedded = [0x01, 0x02, 0x03]; + Convert.ToHexString(ConnectionManagerServices.UnconnectedSend(embedded, [0x01, 0x00])).ShouldBe( + "520220062401" + "0A05" + "0300" + "010203" + "00" + "01" + "00" + "0100"); + } + + [Fact] + public void Refused_forward_open_surfaces_statuses() + { + var reply = CipReply.Parse(Convert.FromHexString("DB000101" + "0901")); + var ex = Should.Throw(() => ConnectionManagerServices.ParseForwardOpenReply(reply)); + ex.Message.ShouldContain("0x01"); + ex.Message.ShouldContain("0x0109"); + } + + [Theory] + [InlineData("DB000800", true)] // Service Not Supported + [InlineData("DB0001010901", true)] // general 1 / extended 0x0109 + [InlineData("DB0001010001", false)] // general 1 / extended 0x0100 (connection in use): no fallback + [InlineData("DB000000", false)] + public void Fallback_rules(string hex, bool expected) => + ConnectionManagerServices.ShouldFallBackToRegularOpen(CipReply.Parse(Convert.FromHexString(hex))).ShouldBe(expected); + + [Theory] + [InlineData(true, 0)] [InlineData(true, 0x10000)] [InlineData(false, 0)] [InlineData(false, 0x200)] + public void Connection_size_out_of_range_throws(bool large, int size) => + Should.Throw(() => ConnectionManagerServices.ForwardOpen( + new ForwardOpenParams(large, 1, 2, 3, 4, 1, 1_000_000, size, []))); + + [Theory] + [InlineData(true, 1)] [InlineData(true, 0xFFFF)] [InlineData(false, 1)] [InlineData(false, 0x1FF)] + public void Connection_size_bounds_accepted(bool large, int size) => + ConnectionManagerServices.ForwardOpen(new ForwardOpenParams(large, 1, 2, 3, 4, 1, 1_000_000, size, [])).ShouldNotBeEmpty(); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ForwardOpenThrottleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ForwardOpenThrottleTests.cs new file mode 100644 index 00000000..6a4d3d74 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ForwardOpenThrottleTests.cs @@ -0,0 +1,173 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Cip; + +/// Press safety: on fake time, and the storm it exists to stop. +[Collection(CipConnectionStatics.Name)] // the storm test moves CipConnection's static counters +public sealed class ForwardOpenThrottleTests +{ + private static readonly TimeSpan Second = TimeSpan.FromSeconds(1); + private static readonly TimeSpan Guard = TimeSpan.FromSeconds(10); + private static CancellationToken TestCt => TestContext.Current.CancellationToken; + + [Fact] + public async Task Second_attempt_inside_interval_waits() + { + var time = new ManualTimeProvider(); + var throttle = new ForwardOpenThrottle(Second, time); + await throttle.RunAsync(_ => Task.FromResult(1), TestCt); + time.Advance(TimeSpan.FromMilliseconds(400)); + + var started = 0; + var second = throttle.RunAsync(_ => { Interlocked.Increment(ref started); return Task.FromResult(2); }, TestCt); + await time.WaitForTimerDueAtAsync(time.Start + Second, Guard); // waits for the remainder, measured from attempt 1's start + time.Advance(TimeSpan.FromMilliseconds(599)); + Volatile.Read(ref started).ShouldBe(0); + second.IsCompleted.ShouldBeFalse(); + + time.Advance(TimeSpan.FromMilliseconds(1)); + (await second.WaitAsync(Guard, TestCt)).ShouldBe(2); + Volatile.Read(ref started).ShouldBe(1); + } + + [Fact] + public async Task Attempt_after_interval_proceeds_immediately() + { + var time = new ManualTimeProvider(); + var throttle = new ForwardOpenThrottle(Second, time); + await throttle.RunAsync(_ => Task.FromResult(1), TestCt); + time.Advance(Second); + + var started = false; + var second = throttle.RunAsync(_ => { started = true; return Task.FromResult(2); }, TestCt); + + started.ShouldBeTrue(); // ran synchronously: no wait was needed + second.IsCompletedSuccessfully.ShouldBeTrue(); + time.ArmedTimers.ShouldBe(0); + } + + [Fact] + public async Task Only_one_attempt_in_flight() + { + var time = new ManualTimeProvider(); + var throttle = new ForwardOpenThrottle(Second, time); + var inFlight = 0; + var maxInFlight = 0; + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + async Task Attempt(CancellationToken _) + { + var now = Interlocked.Increment(ref inFlight); + InterlockedMax(ref maxInFlight, now); + await release.Task; + Interlocked.Decrement(ref inFlight); + return now; + } + + var first = throttle.RunAsync(Attempt, TestCt); + time.Advance(TimeSpan.FromSeconds(5)); // the interval is no obstacle; only the in-flight one is + var others = Enumerable.Range(0, 4).Select(_ => throttle.RunAsync(Attempt, TestCt)).ToArray(); + await Task.Delay(50, TestCt); + Volatile.Read(ref inFlight).ShouldBe(1); + others.ShouldAllBe(t => !t.IsCompleted); + + release.SetResult(); + await first.WaitAsync(Guard, TestCt); + // Attempt 2 starts at once (attempt 1 started 5 s ago); each later one then waits out the + // interval from its predecessor's start. + await others[0].WaitAsync(Guard, TestCt); + for (var i = 1; i < others.Length; i++) + { + await time.WaitForTimerDueAtAsync(time.GetUtcNow() + Second, Guard); + time.Advance(Second); + await FakeCipTarget.Until(() => others.Count(t => t.IsCompleted) == i + 1, Guard, $"attempt {i + 2}"); + } + Volatile.Read(ref maxInFlight).ShouldBe(1); + } + + [Fact] + public async Task Caller_deadline_while_waiting_throws_throttled() + { + var time = new ManualTimeProvider(); + var throttle = new ForwardOpenThrottle(Second, time); + await throttle.RunAsync(_ => Task.FromResult(1), TestCt); + using var cts = new CancellationTokenSource(); + var invoked = false; + + var second = throttle.RunAsync(_ => { invoked = true; return Task.FromResult(2); }, cts.Token); + await time.WaitForTimerDueAtAsync(time.Start + Second, Guard); + await cts.CancelAsync(); + + var ex = await Should.ThrowAsync(() => second.WaitAsync(Guard, TestCt)); + ex.Status.ShouldBe(AbStatus.BadConnection); + ex.Message.ShouldContain("Forward Open throttled"); + invoked.ShouldBeFalse(); + } + + [Fact] + public async Task A_wall_clock_step_does_not_shorten_the_interval() + { + var time = new ManualTimeProvider(); + var throttle = new ForwardOpenThrottle(Second, time); + await throttle.RunAsync(_ => Task.FromResult(1), TestCt); + + time.WallClockOffset = TimeSpan.FromHours(1); // NTP / DST / operator steps the clock forward + var started = 0; + var second = throttle.RunAsync(_ => { Interlocked.Increment(ref started); return Task.FromResult(2); }, TestCt); + + await time.WaitForTimerDueAtAsync(time.Start + Second, Guard); // still parked for the full interval + Volatile.Read(ref started).ShouldBe(0); + time.Advance(Second); + (await second.WaitAsync(Guard, TestCt)).ShouldBe(2); + } + + [Fact] + public async Task Refusing_target_sees_at_most_one_forward_open_per_interval() + { + // The storm: 50 callers hammering a device that refuses every Forward Open, for 3 s of fake time. + // Attempt starts are >= 1 s apart, so t = 0, 1, 2, 3 is the most there can be. + await using var target = new FakeCipTarget { ForwardOpenReply = FakeCipTarget.Refuse(0x01, 0x0113) }; + var time = new ManualTimeProvider(); + var options = new ConnectionOptions { ConnectionSize = 504, Timeout = TimeSpan.FromSeconds(2) }; + var conn = new CipConnection(new DeviceKey("127.0.0.1", target.Port, "1,0", "ControlLogix"), options, + new ForwardOpenThrottle(options.MinForwardOpenInterval, time), time); + using var stop = CancellationTokenSource.CreateLinkedTokenSource(TestCt); + var failures = 0; + + var callers = Enumerable.Range(0, 50).Select(_ => Task.Run(async () => + { + while (!stop.IsCancellationRequested) + { + try { await conn.SendAsync(new byte[] { 0x4C, 0x00 }, stop.Token); } + catch (AbTransportException) { Interlocked.Increment(ref failures); } + catch (OperationCanceledException) { /* stopping */ } + } + }, TestCt)).ToArray(); + + for (var step = 0; step < 30; step++) + { + await Task.Delay(10, TestCt); + time.Advance(TimeSpan.FromMilliseconds(100)); + } + await Task.Delay(100, TestCt); // let any attempt released by the last step reach the wire + + await stop.CancelAsync(); + await Task.WhenAll(callers).WaitAsync(Guard, TestCt); + await conn.DisposeAsync(); + + time.GetUtcNow().ShouldBe(time.Start + TimeSpan.FromSeconds(3)); + target.ForwardOpens.ShouldBeLessThanOrEqualTo(4); + target.ForwardOpens.ShouldBeGreaterThanOrEqualTo(1); + Volatile.Read(ref failures).ShouldBeGreaterThanOrEqualTo(target.ForwardOpens); + target.ConnectedRequests.ShouldBe(0); + } + + private static void InterlockedMax(ref int location, int value) + { + int current; + while (value > (current = Volatile.Read(ref location)) && + Interlocked.CompareExchange(ref location, value, current) != current) { } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/MultipleServicePacketTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/MultipleServicePacketTests.cs new file mode 100644 index 00000000..ad2b9547 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/MultipleServicePacketTests.cs @@ -0,0 +1,52 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Cip; + +public sealed class MultipleServicePacketTests +{ + [Fact] + public void Msp_offsets_are_measured_from_the_count_field() + { + var a = new byte[] { 0x4C, 0x02, 0x91, 0x01, 0x41, 0x00, 0x01, 0x00 }; + var b = new byte[] { 0x4C, 0x02, 0x91, 0x01, 0x42, 0x00, 0x01, 0x00 }; + Convert.ToHexString(MultipleServicePacket.Build([a, b])).ShouldBe( + "0A02200224010200" + "0600" + "0E00" + Convert.ToHexString(a) + Convert.ToHexString(b)); + MultipleServicePacket.BuildSize([8, 8]).ShouldBe(6 + 2 + 4 + 16); + } + + [Fact] + public void BuildSize_equals_built_length() + { + byte[][] reqs = [new byte[7], new byte[20], new byte[1]]; + MultipleServicePacket.Build(reqs).Length.ShouldBe(MultipleServicePacket.BuildSize([7, 20, 1])); + } + + [Fact] + public void Parse_splits_replies() + { + // outer reply data: count 2, offsets 6 and 14, reply1 (8 bytes), reply2 (6 bytes) + var r1 = Convert.FromHexString("CC000000C400" + "0100"); // DINT-typed read reply, 8 bytes + var r2 = Convert.FromHexString("CD000000" + "0000"); // write ok + 2 extra bytes + var data = Convert.FromHexString("0200" + "0600" + "0E00").Concat(r1).Concat(r2).ToArray(); + var outer = new CipReply(0x0A, 0, [], data); + var replies = MultipleServicePacket.Parse(outer, 2); + replies.Count.ShouldBe(2); + replies[0].Service.ShouldBe((byte)0x4C); + Convert.ToHexString(replies[0].Data.ToArray()).ShouldBe("C4000100"); + replies[1].Service.ShouldBe((byte)0x4D); + } + + [Fact] + public void Parse_embedded_error_status_is_kept_per_reply() + { + var r1 = Convert.FromHexString("CC000400"); + var data = Convert.FromHexString("0100" + "0400").Concat(r1).ToArray(); + MultipleServicePacket.Parse(new CipReply(0x0A, 0x1E, [], data), 1)[0].GeneralStatus.ShouldBe((byte)0x04); + } + + [Fact] + public void Parse_wrong_count_throws() => + Should.Throw(() => + MultipleServicePacket.Parse(new CipReply(0x0A, 0, [], Convert.FromHexString("0100" + "0400" + "CC000000")), 2)); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/RequestSchedulerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/RequestSchedulerTests.cs new file mode 100644 index 00000000..8518f296 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/RequestSchedulerTests.cs @@ -0,0 +1,440 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Cip; + +/// +/// Deterministic scheduler tests: requests are held in flight with +/// (or a per-call hold) and the queue is observed through test hooks, never with sleeps. +/// +public sealed class RequestSchedulerTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + /// A request of exactly bytes, service 0x4C, tagged by . + private static byte[] Req(int size, byte tag = 0) + { + var r = new byte[size]; + r[0] = 0x4C; + if (size > 1) r[^1] = tag; + return r; + } + + /// Puts in flight behind the channel gate and returns its task. + private static async Task> HoldFirstAsync(RequestScheduler s, FakeCipChannel ch, byte[] first) + { + var expected = ch.SentCount + 1; + ch.Gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var t = s.SendAsync(first, CancellationToken.None); + await ch.WaitForSendsAsync(expected); + return t; + } + + [Fact] + public async Task Requests_queued_behind_an_inflight_one_are_packed_into_one_msp() + { + var ch = new FakeCipChannel(); + await using var s = new RequestScheduler(ch, allowPacking: true); + var a = await HoldFirstAsync(s, ch, Req(8, 1)); + var b = s.SendAsync(Req(8, 2), CancellationToken.None); + var c = s.SendAsync(Req(8, 3), CancellationToken.None); + var d = s.SendAsync(Req(8, 4), CancellationToken.None); + s.QueuedCount.ShouldBe(3); + + ch.Gate!.SetResult(); + await Task.WhenAll(a, b, c, d).WaitAsync(Timeout, Ct); + + ch.Sent.Count.ShouldBe(2); + ch.Sent[0].ShouldBe(Req(8, 1)); + ch.Sent[1][0].ShouldBe((byte)0x0A); + FakeCipChannel.SplitMsp(ch.Sent[1]).ShouldBe([Req(8, 2), Req(8, 3), Req(8, 4)]); // FIFO + foreach (var t in new[] { a, b, c, d }) (await t).GeneralStatus.ShouldBe((byte)0); + } + + [Fact] + public async Task Msp_exactly_at_limit_packs() + { + // MSP of two 10-byte requests = 6 + 2 + 2*2 + 20 = 32. + MultipleServicePacket.BuildSize([10, 10]).ShouldBe(32); + var ch = new FakeCipChannel(maxRequestBytes: 32); + await using var s = new RequestScheduler(ch, allowPacking: true); + var a = await HoldFirstAsync(s, ch, Req(4)); + var x = s.SendAsync(Req(10, 1), CancellationToken.None); + var y = s.SendAsync(Req(10, 2), CancellationToken.None); + ch.Gate!.SetResult(); + await Task.WhenAll(a, x, y).WaitAsync(Timeout, Ct); + + ch.Sent.Count.ShouldBe(2); + ch.Sent[1].Length.ShouldBe(32); + FakeCipChannel.SplitMsp(ch.Sent[1]).ShouldBe([Req(10, 1), Req(10, 2)]); + } + + [Fact] + public async Task Msp_one_byte_over_limit_splits() + { + var ch = new FakeCipChannel(maxRequestBytes: 31); + await using var s = new RequestScheduler(ch, allowPacking: true); + var a = await HoldFirstAsync(s, ch, Req(4)); + var x = s.SendAsync(Req(10, 1), CancellationToken.None); + var y = s.SendAsync(Req(10, 2), CancellationToken.None); + ch.Gate!.SetResult(); + await Task.WhenAll(a, x, y).WaitAsync(Timeout, Ct); + + ch.Sent.Count.ShouldBe(3); + ch.Sent[1].ShouldBe(Req(10, 1)); + ch.Sent[2].ShouldBe(Req(10, 2)); + } + + [Fact] + public async Task A_request_that_does_not_fit_never_lets_a_later_one_overtake_it() + { + var ch = new FakeCipChannel(maxRequestBytes: 100); + await using var s = new RequestScheduler(ch, allowPacking: true); + var a = await HoldFirstAsync(s, ch, Req(4, 1)); + var b = s.SendAsync(Req(10, 2), CancellationToken.None); + var big = s.SendAsync(Req(400, 3), CancellationToken.None); + var d = s.SendAsync(Req(10, 4), CancellationToken.None); + ch.Gate!.SetResult(); + await Task.WhenAll(a, b, big, d).WaitAsync(Timeout, Ct); + + // b + big does not fit, so b goes bare; big exceeds the limit alone and goes bare; then d. + ch.Sent.Select(r => r[^1]).ShouldBe(new byte[] { 1, 2, 3, 4 }); + ch.Sent.ShouldAllBe(r => r[0] == 0x4C); + } + + [Fact] + public async Task A_single_request_larger_than_the_limit_is_sent_bare() + { + var ch = new FakeCipChannel(maxRequestBytes: 20); + await using var s = new RequestScheduler(ch, allowPacking: true); + var reply = await s.SendAsync(Req(30, 9), CancellationToken.None).WaitAsync(Timeout, Ct); + + reply.GeneralStatus.ShouldBe((byte)0); + ch.Sent.Count.ShouldBe(1); + ch.Sent[0].ShouldBe(Req(30, 9)); + } + + [Fact] + public async Task Packing_disabled_sends_each_bare() + { + var ch = new FakeCipChannel(); + await using var s = new RequestScheduler(ch, allowPacking: false); + var a = await HoldFirstAsync(s, ch, Req(8, 1)); + var b = s.SendAsync(Req(8, 2), CancellationToken.None); + var c = s.SendAsync(Req(8, 3), CancellationToken.None); + ch.Gate!.SetResult(); + await Task.WhenAll(a, b, c).WaitAsync(Timeout, Ct); + + ch.Sent.Count.ShouldBe(3); + ch.Sent.Select(r => r[^1]).ShouldBe(new byte[] { 1, 2, 3 }); + ch.Sent.ShouldAllBe(r => r[0] == 0x4C); + } + + [Fact] + public async Task Per_service_error_fails_only_its_waiter() + { + var ch = new FakeCipChannel + { + RespondPerService = r => r[^1] == 2 + ? FakeCipChannel.Reply(r[0], 0x04, [], 0x0000) + : FakeCipChannel.Reply(r[0], 0x00, [0xAB]), + }; + await using var s = new RequestScheduler(ch, allowPacking: true); + var a = await HoldFirstAsync(s, ch, Req(8, 0)); + var ok = s.SendAsync(Req(8, 1), CancellationToken.None); + var bad = s.SendAsync(Req(8, 2), CancellationToken.None); + ch.Gate!.SetResult(); + await Task.WhenAll(a, ok, bad).WaitAsync(Timeout, Ct); + + ch.Sent.Count.ShouldBe(2); + var okReply = await ok; + okReply.GeneralStatus.ShouldBe((byte)0x00); + okReply.Status.ShouldBe(AbStatus.Ok); + okReply.Data.ToArray().ShouldBe(new byte[] { 0xAB }); + var badReply = await bad; + badReply.GeneralStatus.ShouldBe((byte)0x04); + badReply.ExtendedStatus.ShouldBe(new ushort[] { 0x0000 }); + } + + [Fact] + public async Task Msp_envelope_failure_faults_every_waiter_in_the_batch_with_the_same_exception() + { + var ch = new FakeCipChannel(); + await using var s = new RequestScheduler(ch, allowPacking: true); + var a = await HoldFirstAsync(s, ch, Req(8, 0)); + ch.Respond = r => Task.FromResult(r[0] == 0x0A + ? FakeCipChannel.Reply(0x0A, 0x08, []) // service not supported on the envelope + : FakeCipChannel.OkReply(r)); + var x = s.SendAsync(Req(8, 1), CancellationToken.None); + var y = s.SendAsync(Req(8, 2), CancellationToken.None); + ch.Gate!.SetResult(); + + (await a.WaitAsync(Timeout, Ct)).GeneralStatus.ShouldBe((byte)0); + var ex1 = await Should.ThrowAsync(() => x.WaitAsync(Timeout, Ct)); + var ex2 = await Should.ThrowAsync(() => y.WaitAsync(Timeout, Ct)); + ex1.ShouldBeSameAs(ex2); + ex1.Status.ShouldBe(AbStatusCodes.FromCip(0x08, [])); + } + + [Fact] + public async Task Channel_failure_faults_every_waiter_in_the_batch() + { + var ch = new FakeCipChannel(); + await using var s = new RequestScheduler(ch, allowPacking: true); + var a = await HoldFirstAsync(s, ch, Req(8, 0)); + var boom = new AbTransportException(AbStatus.Timeout, "reply timeout"); + ch.Respond = _ => Task.FromException(boom); + var x = s.SendAsync(Req(8, 1), CancellationToken.None); + var y = s.SendAsync(Req(8, 2), CancellationToken.None); + ch.Gate!.SetResult(); + + // a's own exchange also failed (the responder was swapped before release). + (await Should.ThrowAsync(() => a.WaitAsync(Timeout, Ct))).ShouldBeSameAs(boom); + (await Should.ThrowAsync(() => x.WaitAsync(Timeout, Ct))).ShouldBeSameAs(boom); + (await Should.ThrowAsync(() => y.WaitAsync(Timeout, Ct))).ShouldBeSameAs(boom); + + // The scheduler survives a failed exchange. + ch.Respond = ch.DefaultRespond; + (await s.SendAsync(Req(8, 3), CancellationToken.None).WaitAsync(Timeout, Ct)).GeneralStatus.ShouldBe((byte)0); + } + + [Fact] + public async Task A_non_transport_channel_exception_is_wrapped_and_shared_by_the_batch() + { + var ch = new FakeCipChannel(); + await using var s = new RequestScheduler(ch, allowPacking: true); + var a = await HoldFirstAsync(s, ch, Req(8, 0)); + ch.Respond = _ => Task.FromException(new IOException("socket reset")); + var x = s.SendAsync(Req(8, 1), CancellationToken.None); + var y = s.SendAsync(Req(8, 2), CancellationToken.None); + ch.Gate!.SetResult(); + + await Should.ThrowAsync(() => a.WaitAsync(Timeout, Ct)); + var ex1 = await Should.ThrowAsync(() => x.WaitAsync(Timeout, Ct)); + var ex2 = await Should.ThrowAsync(() => y.WaitAsync(Timeout, Ct)); + ex1.ShouldBeSameAs(ex2); + ex1.InnerException.ShouldBeOfType(); + } + + [Fact] + public async Task Queued_waiter_cancelled_before_send_is_never_sent() + { + var ch = new FakeCipChannel(); + await using var s = new RequestScheduler(ch, allowPacking: true); + var a = await HoldFirstAsync(s, ch, Req(8, 1)); + using var cts = new CancellationTokenSource(); + var b = s.SendAsync(Req(8, 2), cts.Token); + s.QueuedCount.ShouldBe(1); + + await cts.CancelAsync(); + await Should.ThrowAsync(() => b.WaitAsync(Timeout, Ct)); + b.IsCanceled.ShouldBeTrue(); + s.QueuedCount.ShouldBe(0); + + var c = s.SendAsync(Req(8, 3), CancellationToken.None); + ch.Gate!.SetResult(); + await Task.WhenAll(a, c).WaitAsync(Timeout, Ct); + + ch.Sent.Count.ShouldBe(2); + ch.Sent.Select(r => r[^1]).ShouldBe(new byte[] { 1, 3 }); + ch.Sent[1][0].ShouldBe((byte)0x4C); // c went bare: the cancelled b was not packed with it + } + + [Fact] + public async Task A_scheduler_fault_outside_the_exchange_fails_every_pending_waiter_and_keeps_pumping() + { + var ch = new FakeCipChannel(); + await using var s = new RequestScheduler(ch, allowPacking: true); + var a = await HoldFirstAsync(s, ch, Req(8, 1)); + var b = s.SendAsync(Req(8, 2), CancellationToken.None); + var c = s.SendAsync(Req(8, 3), CancellationToken.None); + var d = s.SendAsync(Req(8, 4), CancellationToken.None); + var broken = new InvalidOperationException("channel torn"); + ch.MaxRequestBytesThrows = broken; // TakeBatch reads it when packing c behind b + ch.Gate!.SetResult(); + + (await a.WaitAsync(Timeout, Ct)).GeneralStatus.ShouldBe((byte)0); + var exB = await Should.ThrowAsync(() => b.WaitAsync(Timeout, Ct)); // already taken + var exC = await Should.ThrowAsync(() => c.WaitAsync(Timeout, Ct)); // still queued + var exD = await Should.ThrowAsync(() => d.WaitAsync(Timeout, Ct)); + exB.Status.ShouldBe(AbStatus.BadConnection); + exB.InnerException.ShouldBeSameAs(broken); + exC.ShouldBeSameAs(exB); + exD.ShouldBeSameAs(exB); + s.QueuedCount.ShouldBe(0); + ch.SentCount.ShouldBe(1); + + // The pump survived: a later request is answered, not left hanging. + ch.MaxRequestBytesThrows = null; + (await s.SendAsync(Req(8, 5), CancellationToken.None).WaitAsync(Timeout, Ct)).GeneralStatus.ShouldBe((byte)0); + } + + [Fact] + public async Task A_token_already_cancelled_is_never_queued() + { + var ch = new FakeCipChannel(); + await using var s = new RequestScheduler(ch, allowPacking: true); + var t = s.SendAsync(Req(8), new CancellationToken(canceled: true)); + t.IsCanceled.ShouldBeTrue(); + s.QueuedCount.ShouldBe(0); + s.LiveRegistrations.ShouldBe(0); + ch.SentCount.ShouldBe(0); + } + + [Fact] + public async Task A_waiter_cancelled_while_its_batch_is_in_flight_is_abandoned_and_the_others_complete() + { + var ch = new FakeCipChannel(); + var mspHold = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ch.Respond = async r => + { + if (r[0] == 0x0A) await mspHold.Task; + return await ch.DefaultRespond(r); + }; + await using var s = new RequestScheduler(ch, allowPacking: true); + var a = await HoldFirstAsync(s, ch, Req(8, 1)); + using var cts = new CancellationTokenSource(); + var b = s.SendAsync(Req(8, 2), cts.Token); + var c = s.SendAsync(Req(8, 3), CancellationToken.None); + ch.Gate!.SetResult(); + await ch.WaitForSendsAsync(2); // the MSP carrying b and c is now in flight + s.QueuedCount.ShouldBe(0); + + await cts.CancelAsync(); + await Should.ThrowAsync(() => b.WaitAsync(Timeout, Ct)); + c.IsCompleted.ShouldBeFalse(); + ch.Tokens[1].IsCancellationRequested.ShouldBeFalse(); // the shared exchange was not aborted + + mspHold.SetResult(); + (await c.WaitAsync(Timeout, Ct)).GeneralStatus.ShouldBe((byte)0); + (await a.WaitAsync(Timeout, Ct)).GeneralStatus.ShouldBe((byte)0); + s.LiveRegistrations.ShouldBe(0); + } + + [Fact] + public async Task Waiter_cancellation_never_reaches_the_channel_token() + { + var ch = new FakeCipChannel(); + await using var s = new RequestScheduler(ch, allowPacking: true); + using var w1 = new CancellationTokenSource(); + using var w2 = new CancellationTokenSource(); + using var w3 = new CancellationTokenSource(); + + var a = await HoldFirstAsync(s, ch, Req(8, 1)); // bare single, no waiter token + var single = s.SendAsync(Req(8, 2), w1.Token); + ch.Gate!.SetResult(); + await Task.WhenAll(a, single).WaitAsync(Timeout, Ct); // bare single with a waiter token + + var held = await HoldFirstAsync(s, ch, Req(8, 3)); + var m1 = s.SendAsync(Req(8, 4), w2.Token); + var m2 = s.SendAsync(Req(8, 5), w3.Token); + ch.Gate!.SetResult(); + await Task.WhenAll(held, m1, m2).WaitAsync(Timeout, Ct); // MSP with waiter tokens + + ch.Sent.Count.ShouldBe(4); + ch.Sent[3][0].ShouldBe((byte)0x0A); + var waiterTokens = new[] { w1.Token, w2.Token, w3.Token }; + ch.Tokens.ShouldAllBe(t => !waiterTokens.Contains(t)); + ch.Tokens.Distinct().Count().ShouldBe(1); // every call used the scheduler's own stop token + ch.Tokens[0].CanBeCanceled.ShouldBeTrue(); + } + + [Fact] + public async Task Cancellation_registrations_are_released() + { + var ch = new FakeCipChannel(); + await using var s = new RequestScheduler(ch, allowPacking: true); + using var longLived = new CancellationTokenSource(); + + // The hook really counts: queued waiters hold live registrations. + var a = await HoldFirstAsync(s, ch, Req(8)); + var queued = Enumerable.Range(0, 5).Select(_ => s.SendAsync(Req(8), longLived.Token)).ToArray(); + s.LiveRegistrations.ShouldBe(5); + ch.Gate!.SetResult(); + await Task.WhenAll(queued.Append(a)).WaitAsync(Timeout, Ct); + s.LiveRegistrations.ShouldBe(0); + ch.Gate = null; + + var tasks = new Task[10_000]; + for (var i = 0; i < tasks.Length; i++) tasks[i] = s.SendAsync(Req(8), longLived.Token); + await Task.WhenAll(tasks).WaitAsync(TimeSpan.FromSeconds(60), Ct); + + s.LiveRegistrations.ShouldBe(0); + // Cancelling the long-lived token afterwards touches nothing: every registration is gone. + await longLived.CancelAsync(); + s.CancellationCallbacksRun.ShouldBe(0); + tasks.ShouldAllBe(t => t.IsCompletedSuccessfully); + } + + [Fact] + public async Task Dispose_faults_queued_and_in_flight_waiters_as_a_communication_failure_not_a_cancellation() + { + var ch = new FakeCipChannel(); + var s = new RequestScheduler(ch, allowPacking: true); + var a = await HoldFirstAsync(s, ch, Req(8, 1)); // in flight + using var own = new CancellationTokenSource(); + var b = s.SendAsync(Req(8, 2), own.Token); // queued, with a live token + + await s.DisposeAsync(); + + // Not OperationCanceledException: upstream would swallow that as "unsubscribed". + var inFlight = await Should.ThrowAsync(() => a.WaitAsync(Timeout, Ct)); + inFlight.Status.ShouldBe(AbStatus.BadConnection); + var queued = await Should.ThrowAsync(() => b.WaitAsync(Timeout, Ct)); + queued.Status.ShouldBe(AbStatus.BadConnection); + a.IsCanceled.ShouldBeFalse(); + b.IsCanceled.ShouldBeFalse(); + s.LiveRegistrations.ShouldBe(0); + ch.SentCount.ShouldBe(1); + Should.Throw(() => s.SendAsync(Req(8), CancellationToken.None)); + } + + private static bool IsMsp(byte[] r) => r[0] == 0x0A; + + [Fact] + public async Task An_exclusive_request_goes_out_bare_and_splits_the_packing_around_it_in_fifo_order() + { + var ch = new FakeCipChannel(); + await using var s = new RequestScheduler(ch, allowPacking: true); + var a = await HoldFirstAsync(s, ch, Req(8, 1)); + + var b1 = s.SendAsync(Req(8, 2), Ct); + var b2 = s.SendAsync(Req(8, 3), Ct); + var c = s.SendAsync(Req(8, 4), Ct, exclusive: true); + var d1 = s.SendAsync(Req(8, 5), Ct); + var d2 = s.SendAsync(Req(8, 6), Ct); + s.QueuedCount.ShouldBe(5); + ch.Gate!.SetResult(); + await Task.WhenAll(a, b1, b2, c, d1, d2).WaitAsync(Timeout, Ct); + + // A, then [B1 B2] packed, C bare, then [D1 D2] packed: the exclusive request is a batch boundary. + ch.Sent.Count.ShouldBe(4); + IsMsp(ch.Sent[1]).ShouldBeTrue(); + FakeCipChannel.SplitMsp(ch.Sent[1]).Select(e => e[^1]).ShouldBe(new byte[] { 2, 3 }); + IsMsp(ch.Sent[2]).ShouldBeFalse(); + ch.Sent[2][^1].ShouldBe((byte)4); + IsMsp(ch.Sent[3]).ShouldBeTrue(); + FakeCipChannel.SplitMsp(ch.Sent[3]).Select(e => e[^1]).ShouldBe(new byte[] { 5, 6 }); + } + + [Fact] + public async Task An_exclusive_request_at_the_head_of_the_queue_does_not_take_followers_with_it() + { + var ch = new FakeCipChannel(); + await using var s = new RequestScheduler(ch, allowPacking: true); + var a = await HoldFirstAsync(s, ch, Req(8, 1)); + + var c = s.SendAsync(Req(8, 2), Ct, exclusive: true); + var d1 = s.SendAsync(Req(8, 3), Ct); + var d2 = s.SendAsync(Req(8, 4), Ct); + ch.Gate!.SetResult(); + await Task.WhenAll(a, c, d1, d2).WaitAsync(Timeout, Ct); + + ch.Sent.Count.ShouldBe(3); + IsMsp(ch.Sent[1]).ShouldBeFalse(); + ch.Sent[1][^1].ShouldBe((byte)2); + FakeCipChannel.SplitMsp(ch.Sent[2]).Select(e => e[^1]).ShouldBe(new byte[] { 3, 4 }); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/DeviceSessionPoolTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/DeviceSessionPoolTests.cs new file mode 100644 index 00000000..bf14ebf9 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/DeviceSessionPoolTests.cs @@ -0,0 +1,287 @@ +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Cip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests; + +[Collection(CipConnectionStatics.Name)] +public sealed class DeviceSessionPoolTests +{ + private static readonly TimeSpan Guard = TimeSpan.FromSeconds(10); + private static readonly byte[] ReadTag = [0x4C, 0x02, 0x91, 0x03, 0x54, 0x61, 0x67, 0x00, 0x01, 0x00]; + private static CancellationToken TestCt => TestContext.Current.CancellationToken; + + private static ConnectionOptions Options(int size = 4002) => new() { ConnectionSize = size, Timeout = TimeSpan.FromSeconds(2) }; + + [Fact] + public void Device_keys_compare_host_and_plc_attribute_case_insensitively() + { + var a = new DeviceKey("Press-07.plant.local", 44818, "1,0", "ControlLogix"); + var b = new DeviceKey("PRESS-07.PLANT.LOCAL", 44818, "1,0", "controllogix"); + a.ShouldBe(b); + a.GetHashCode().ShouldBe(b.GetHashCode()); + a.ShouldNotBe(a with { CipPath = "1,1" }); + a.ShouldNotBe(a with { Port = 2222 }); + } + + [Fact] + public async Task Leases_for_one_key_share_one_scheduler() + { + var pool = new DeviceSessionPool(new ManualTimeProvider()); + var key = new DeviceKey("plc1", 44818, "1,0", "ControlLogix"); + + await using var a = pool.Acquire(key, Options()); + await using var b = pool.Acquire(key with { Host = "PLC1" }, Options()); + await using var other = pool.Acquire(key with { CipPath = "1,2" }, Options()); + + b.Scheduler.ShouldBeSameAs(a.Scheduler); + b.Channel.ShouldBeSameAs(a.Channel); + other.Scheduler.ShouldNotBeSameAs(a.Scheduler); + other.Channel.ShouldNotBeSameAs(a.Channel); + pool.EntryCount.ShouldBe(2); + } + + [Fact] + public async Task A_second_acquire_with_different_options_reuses_the_first_and_logs_once() + { + var logger = new CapturingLogger(); + var pool = new DeviceSessionPool(new ManualTimeProvider(), logger); + var key = new DeviceKey("plc1", 44818, "1,0", "ControlLogix"); + var first = Options(4002); + var second = Options(504) with { AllowPacking = true }; + + await using var a = pool.Acquire(key, first); + await using var b = pool.Acquire(key, second); + await using var c = pool.Acquire(key, second); + await using var d = pool.Acquire(key, first); + + ((CipConnection)b.Channel).Options.ShouldBe(first); + ((CipConnection)c.Channel).Options.ShouldBe(first); + logger.Warnings.ShouldBe(1); + } + + [Fact] + public async Task Last_lease_dispose_sends_forward_close_then_unregister() + { + await using var target = new FakeCipTarget(); + var pool = new DeviceSessionPool(new ManualTimeProvider()); + var key = new DeviceKey("127.0.0.1", target.Port, "1,0", "ControlLogix"); + var a = pool.Acquire(key, Options()); + var b = pool.Acquire(key, Options()); + (await a.Scheduler.SendAsync(ReadTag, TestCt)).GeneralStatus.ShouldBe((byte)0); + + await a.DisposeAsync(); + await a.DisposeAsync(); // idempotent: must not release b's reference + target.ForwardCloses.ShouldBe(0); + (await b.Scheduler.SendAsync(ReadTag, TestCt)).GeneralStatus.ShouldBe((byte)0); + + await b.DisposeAsync(); + await target.Server.WaitForFrameAsync(f => FakeEipServer.Command(f) == EncapCommand.UnRegisterSession, Guard); + + target.ForwardCloses.ShouldBe(1); + var frames = target.Server.Received.ToList(); + var close = frames.FindIndex(f => FakeEipServer.Command(f) == EncapCommand.SendRRData + && FakeCipTarget.IsForwardClose(FakeEipServer.RequestCip(f))); + var unregister = frames.FindIndex(f => FakeEipServer.Command(f) == EncapCommand.UnRegisterSession); + close.ShouldBeGreaterThanOrEqualTo(0); + close.ShouldBeLessThan(unregister); + // The Forward Close names the connection that was opened. + target.ForwardCloseRequests.Single().AsSpan(8, 8).ToArray() + .ShouldBe(target.ForwardOpenRequests.Single().AsSpan(16, 8).ToArray()); + pool.EntryCount.ShouldBe(0); + await Should.ThrowAsync(() => b.Scheduler.SendAsync(ReadTag, TestCt)); + } + + [Fact] + public async Task A_later_acquire_builds_new_instances() + { + var pool = new DeviceSessionPool(new ManualTimeProvider()); + var key = new DeviceKey("plc1", 44818, "1,0", "ControlLogix"); + var first = pool.Acquire(key, Options()); + var scheduler = first.Scheduler; + var channel = first.Channel; + await first.DisposeAsync(); + + await using var second = pool.Acquire(key, Options()); + + second.Scheduler.ShouldNotBeSameAs(scheduler); + second.Channel.ShouldNotBeSameAs(channel); + } + + [Fact] + public async Task A_reconnect_after_the_last_release_is_still_throttled() + { + await using var target = new FakeCipTarget(); + var time = new ManualTimeProvider(); + var pool = new DeviceSessionPool(time); + var key = new DeviceKey("127.0.0.1", target.Port, "1,0", "ControlLogix"); + + await using (var first = pool.Acquire(key, Options())) + await first.Scheduler.SendAsync(ReadTag, TestCt); // Forward Open at fake t0 + + await using var second = pool.Acquire(key, Options()); + var send = second.Scheduler.SendAsync(ReadTag, TestCt); + await time.WaitForTimerDueAtAsync(time.Start + TimeSpan.FromSeconds(1), Guard); + target.ForwardOpens.ShouldBe(1); + + time.Advance(TimeSpan.FromSeconds(1)); + await send.WaitAsync(Guard, TestCt); + target.ForwardOpens.ShouldBe(2); + } + + [Fact] + public async Task Concurrent_acquire_and_release_is_safe() + { + var pool = new DeviceSessionPool(new ManualTimeProvider()); + var keys = Enumerable.Range(0, 3).Select(i => new DeviceKey($"plc{i}", 44818, "1,0", "ControlLogix")).ToArray(); + var schedulers = new System.Collections.Concurrent.ConcurrentDictionary<(int, RequestScheduler), byte>(); + + var tasks = Enumerable.Range(0, 100).Select(i => Task.Run(async () => + { + var lease = pool.Acquire(keys[i % 3], Options()); + schedulers.TryAdd((i % 3, lease.Scheduler), 0); + await Task.Yield(); + await lease.DisposeAsync(); + }, TestCt)).ToArray(); + await Task.WhenAll(tasks).WaitAsync(Guard, TestCt); + + pool.EntryCount.ShouldBe(0); + // Every lease got a scheduler for its own key only, never another key's. + schedulers.Keys.GroupBy(k => k.Item2).ShouldAllBe(g => g.Select(k => k.Item1).Distinct().Count() == 1); + } + + [Fact] + public async Task Device_keys_ignore_whitespace_in_the_cip_path() + { + var a = new DeviceKey("plc1", 44818, "1,0", "ControlLogix"); + var b = new DeviceKey("plc1", 44818, " 1, 0 ", "ControlLogix"); + b.CipPath.ShouldBe("1,0"); + b.ShouldBe(a); + b.GetHashCode().ShouldBe(a.GetHashCode()); + (a with { CipPath = "1, 2" }).CipPath.ShouldBe("1,2"); + new DeviceKey("plc1", 44818, " ", "ControlLogix").CipPath.ShouldBe(""); + + var pool = new DeviceSessionPool(new ManualTimeProvider()); + await using var la = pool.Acquire(a, Options()); + await using var lb = pool.Acquire(b, Options()); + lb.Scheduler.ShouldBeSameAs(la.Scheduler); // one connection, one throttle + pool.EntryCount.ShouldBe(1); + } + + [Fact] + public void Whitespace_inside_a_cip_path_part_is_kept_so_a_typo_fails_loudly() + { + var typo = new DeviceKey("plc1", 44818, "1,2 3", "ControlLogix"); + typo.CipPath.ShouldNotBe("1,23"); // never silently slot 23 on another controller + typo.CipPath.ShouldBe("1,2 3"); + Should.Throw(() => CipPath.EncodeRoute(typo.CipPath)); + CipPath.EncodeRoute(new DeviceKey("plc1", 44818, " 1, 0 ", "ControlLogix").CipPath).ShouldBe(new byte[] { 0x01, 0x00 }); + } + + [Fact] + public async Task A_new_connection_waits_for_every_earlier_close_not_just_the_latest() + { + // A is closing (its Forward Close is held); B is acquired and released without ever opening, so + // its own close is instant; C must still not open until A's close has finished. + await using var target = new FakeCipTarget(); + var closeHeld = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + target.ForwardCloseGate = () => closeHeld.Task; + var time = new ManualTimeProvider(); + var pool = new DeviceSessionPool(time); + var key = new DeviceKey("127.0.0.1", target.Port, "1,0", "ControlLogix"); + + var a = pool.Acquire(key, Options()); + await a.Scheduler.SendAsync(ReadTag, TestCt); + var aClosing = a.DisposeAsync().AsTask(); + await FakeCipTarget.Until(() => target.ForwardCloses == 1, Guard, "A's Forward Close"); + + await pool.Acquire(key, Options()).DisposeAsync(); // B: never opened + time.Advance(TimeSpan.FromSeconds(5)); // the throttle is no obstacle for C + + await using var c = pool.Acquire(key, Options()); + var cSend = c.Scheduler.SendAsync(ReadTag, TestCt); + await Task.Delay(300, TestCt); + target.ForwardOpens.ShouldBe(1); // C is held back behind A + cSend.IsCompleted.ShouldBeFalse(); + + closeHeld.SetResult(); + await aClosing.WaitAsync(Guard, TestCt); + (await cSend.WaitAsync(Guard, TestCt)).GeneralStatus.ShouldBe((byte)0); + target.ForwardOpens.ShouldBe(2); + } + + [Fact] + public async Task Disposing_during_an_open_still_forward_closes_what_the_target_accepted() + { + // The scheduler's stop token (and any caller's) ends only the WAIT; the Forward Open on the wire + // completes, and the connection it created is closed. + await using var target = new FakeCipTarget(); + var openHeld = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + target.ForwardOpenGate = () => openHeld.Task; + var pool = new DeviceSessionPool(new ManualTimeProvider()); + var key = new DeviceKey("127.0.0.1", target.Port, "1,0", "ControlLogix"); + + var lease = pool.Acquire(key, Options()); + var send = lease.Scheduler.SendAsync(ReadTag, TestCt); + await FakeCipTarget.Until(() => target.ForwardOpens == 1, Guard, "the Forward Open"); + var disposing = lease.DisposeAsync().AsTask(); + + var failed = await Should.ThrowAsync(() => send.WaitAsync(Guard, TestCt)); + failed.Status.ShouldBe(AbStatus.BadConnection); // the scheduler's waiter is released at once + target.ForwardCloses.ShouldBe(0); + + openHeld.SetResult(); // the target accepts, late + await disposing.WaitAsync(Guard, TestCt); + + target.ForwardCloses.ShouldBe(1); + target.ForwardCloseRequests.Single().AsSpan(8, 8).ToArray() + .ShouldBe(target.ForwardOpenRequests.Single().AsSpan(16, 8).ToArray()); + target.ConnectedRequests.ShouldBe(0); + } + + [Fact] + public async Task The_shared_pool_logger_can_be_configured() + { + var logger = new CapturingLogger(); + DeviceSessionPool.ConfigureSharedLogger(new SingleLoggerFactory(logger)); + try + { + var key = new DeviceKey($"shared-logger-{Guid.NewGuid():N}", 44818, "1,0", "ControlLogix"); + await using var a = DeviceSessionPool.Shared.Acquire(key, Options(4002)); + await using var b = DeviceSessionPool.Shared.Acquire(key, Options(504)); + logger.Warnings.ShouldBe(1); + } + finally + { + DeviceSessionPool.ConfigureSharedLogger(Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance); + } + } + + private sealed class SingleLoggerFactory(ILogger logger) : ILoggerFactory + { + public ILogger CreateLogger(string categoryName) => logger; + public void AddProvider(ILoggerProvider provider) { } + public void Dispose() { } + } + + [Fact] + public void Shared_is_one_process_wide_instance() => + DeviceSessionPool.Shared.ShouldBeSameAs(DeviceSessionPool.Shared); + + private sealed class CapturingLogger : ILogger + { + private int _warnings; + public int Warnings => Volatile.Read(ref _warnings); + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + if (logLevel == LogLevel.Warning) Interlocked.Increment(ref _warnings); + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationClientTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationClientTests.cs new file mode 100644 index 00000000..3392ff68 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationClientTests.cs @@ -0,0 +1,326 @@ +using System.Buffers.Binary; +using System.Net; +using System.Net.Sockets; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Eip; + +/// +/// against . Late-reply scenarios are +/// driven by a TaskCompletionSource the test releases, not by sleeps, so ordering is deterministic. +/// +public sealed class EncapsulationClientTests +{ + private static readonly TimeSpan Generous = TimeSpan.FromSeconds(5); + private static readonly TimeSpan Guard = TimeSpan.FromSeconds(10); + private static CancellationToken TestCt => TestContext.Current.CancellationToken; + + private static Task Connect(FakeEipServer server) => + EncapsulationClient.ConnectAsync("127.0.0.1", server.Port, Generous, TestCt); + + private static bool IsRR(byte[] f) => FakeEipServer.Command(f) == EncapCommand.SendRRData; + + [Fact] + public async Task Registers_and_stores_session_handle() + { + await using var server = new FakeEipServer(); + await using var client = await Connect(server); + client.SessionHandle.ShouldBe(FakeEipServer.Session); + client.IsConnected.ShouldBeTrue(); + FakeEipServer.Command(server.Received[0]).ShouldBe(EncapCommand.RegisterSession); + } + + [Fact] + public async Task RegisterSession_goes_on_the_wire_with_a_zero_sender_context() + { + // ab_server refuses RegisterSession with any non-zero sender context (status 1, header-only + // reply), so the client must not stamp its per-request context on it. Found by Task 17. + await using var server = new FakeEipServer(); + await using var client = await Connect(server); + EncapsulationCodec.ReadHeader(server.Received[0]).SenderContext.ShouldBe(0UL); + } + + [Fact] + public async Task SendRRData_returns_cip_payload() + { + await using var server = new FakeEipServer(); + byte[] cipReply = [0xCC, 0, 0, 0, 0xC4, 0, 1, 0, 0, 0]; + server.Handler = req => FakeEipServer.RRReply(req, cipReply); + await using var client = await Connect(server); + + var result = await client.SendRRDataAsync(new byte[] { 0x4C, 0x02 }, Generous, TestCt); + + result.ToArray().ShouldBe(cipReply); + var sent = server.Received.Single(IsRR); + BinaryPrimitives.ReadUInt32LittleEndian(sent.AsSpan(4)).ShouldBe(FakeEipServer.Session); + FakeEipServer.RequestCip(sent).ShouldBe(new byte[] { 0x4C, 0x02 }); + } + + [Fact] + public async Task Works_against_a_server_that_zeroes_the_reply_sender_context() + { + // ab_server behaviour (Task 4 captures): every reply carries context 0 (R5-2, R5-3). + await using var server = new FakeEipServer { ZeroReplyContext = true }; + server.Handler = req => FakeEipServer.RRReply(req, [0xCC, 0, 0, 0]); + await using var client = await Connect(server); + + client.SessionHandle.ShouldBe(FakeEipServer.Session); + (await client.SendRRDataAsync(new byte[] { 0x4C }, Generous, TestCt)).ToArray().ShouldBe(new byte[] { 0xCC, 0, 0, 0 }); + (await client.SendRRDataAsync(new byte[] { 0x4C }, Generous, TestCt)).ToArray().ShouldBe(new byte[] { 0xCC, 0, 0, 0 }); + } + + [Fact] + public async Task Concurrent_sends_are_serialized() + { + await using var server = new FakeEipServer(); + server.HandlerAsync = async req => + { + await Task.Delay(20, TestCt); + return FakeEipServer.RRReply(req, FakeEipServer.RequestCip(req)); // echo + }; + await using var client = await Connect(server); + + var calls = Enumerable.Range(1, 5) + .Select(i => client.SendRRDataAsync(new[] { (byte)i }, Generous, TestCt)) + .ToArray(); + var results = await Task.WhenAll(calls).WaitAsync(Guard, TestCt); + + // The fake records frames the instant they arrive, independently of its responder, so a client + // that pipelined a second request before the first reply would push this to 2+. + server.MaxOutstanding.ShouldBe(1); + for (var i = 0; i < 5; i++) results[i].ToArray().ShouldBe(new[] { (byte)(i + 1) }); + server.Received.Count(IsRR).ShouldBe(5); + } + + [Fact] + public async Task Stalled_reply_times_out_and_closes_the_socket() + { + await using var server = new FakeEipServer { Stall = true }; + await using var client = await Connect(server); + + var ex = await Should.ThrowAsync( + client.SendRRDataAsync(new byte[] { 0x4C }, TimeSpan.FromMilliseconds(200), TestCt).WaitAsync(Guard, TestCt)); + + ex.Status.ShouldBe(AbStatus.Timeout); + client.IsConnected.ShouldBeFalse(); + (await Should.ThrowAsync(client.SendRRDataAsync(new byte[] { 0x4C }, Generous, TestCt))) + .Status.ShouldBe(AbStatus.BadConnection); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] // R5-3: the late reply carries a ZEROED context, so context matching cannot reject it + public async Task Caller_cancellation_does_not_fault_the_connection_and_the_late_reply_is_never_misattributed(bool zeroContext) + { + await using var server = new FakeEipServer { ZeroReplyContext = zeroContext }; + var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + byte[] stale = [0xCC, 0, 0, 0, 0xAA]; + byte[] fresh = [0xCC, 0, 0, 0, 0xBB]; + server.ScriptAsync( + async req => { await releaseFirst.Task; return FakeEipServer.RRReply(req, stale); }, + req => Task.FromResult(FakeEipServer.RRReply(req, fresh))); + await using var client = await Connect(server); + + using var callerCts = new CancellationTokenSource(); + var first = client.SendRRDataAsync(new byte[] { 0x01 }, Generous, callerCts.Token); + await server.WaitForFrameAsync(IsRR, Guard); // request 1 is on the wire + await callerCts.CancelAsync(); + + await Should.ThrowAsync(first.WaitAsync(Guard, TestCt)); + client.IsConnected.ShouldBeTrue(); + + // Request 2 is issued while request 1's reply is still outstanding; only then does the server + // answer request 1 (late, after the cancel, before the timeout). + var second = client.SendRRDataAsync(new byte[] { 0x02 }, Generous, TestCt); + releaseFirst.SetResult(); + + (await second.WaitAsync(Guard, TestCt)).ToArray().ShouldBe(fresh); + client.IsConnected.ShouldBeTrue(); + server.MaxOutstanding.ShouldBe(1); // request 2 waited for reply 1 (R5-1) + server.Received.Count(IsRR).ShouldBe(2); + server.HandlerErrors.ShouldBeEmpty(); + } + + [Fact] + public async Task Caller_cancellation_then_no_reply_faults_at_the_timeout_and_the_next_call_does_not_hang() + { + await using var server = new FakeEipServer { Stall = true }; + await using var client = await Connect(server); + + using var callerCts = new CancellationTokenSource(); + var first = client.SendRRDataAsync(new byte[] { 0x01 }, TimeSpan.FromMilliseconds(300), callerCts.Token); + await server.WaitForFrameAsync(IsRR, Guard); + await callerCts.CancelAsync(); + await Should.ThrowAsync(first.WaitAsync(Guard, TestCt)); + client.IsConnected.ShouldBeTrue(); // cancel alone never faults + + // Queued behind the draining exchange: must fail fast once that exchange times out, not hang. + var next = client.SendRRDataAsync(new byte[] { 0x02 }, Generous, TestCt); + var ex = await Should.ThrowAsync(next.WaitAsync(Guard, TestCt)); + + ex.Status.ShouldBe(AbStatus.BadConnection); + client.IsConnected.ShouldBeFalse(); + server.Received.Count(IsRR).ShouldBe(1); // the queued request was never written + (await Should.ThrowAsync(client.SendRRDataAsync(new byte[] { 0x03 }, Generous, TestCt) + .WaitAsync(Guard, TestCt))) + .Status.ShouldBe(AbStatus.BadConnection); + } + + [Fact] + public async Task An_abandoned_exchange_that_times_out_leaves_no_unobserved_task_exception() + { + var unobserved = new List(); + EventHandler onUnobserved = (_, e) => + { + if (e.Exception.Flatten().InnerExceptions.Any(x => x.Message.Contains("within 317 ms"))) + lock (unobserved) unobserved.Add(e.Exception); + }; + TaskScheduler.UnobservedTaskException += onUnobserved; + try + { + await AbandonAnExchangeThatTimesOut(); + for (var i = 0; i < 5; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + await Task.Delay(20, TestCt); + } + lock (unobserved) unobserved.ShouldBeEmpty(); + } + finally + { + TaskScheduler.UnobservedTaskException -= onUnobserved; + } + } + + // Kept out of the test body so no local in the test's own state machine roots the abandoned task. + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static async Task AbandonAnExchangeThatTimesOut() + { + await using var server = new FakeEipServer { Stall = true }; + await using var client = await Connect(server); + using (var callerCts = new CancellationTokenSource()) + { + var first = client.SendRRDataAsync(new byte[] { 0x01 }, TimeSpan.FromMilliseconds(317), callerCts.Token); + await server.WaitForFrameAsync(IsRR, Guard); + await callerCts.CancelAsync(); + await Should.ThrowAsync(first.WaitAsync(Guard, TestCt)); + } + var deadline = DateTime.UtcNow + Guard; + while (client.IsConnected && DateTime.UtcNow < deadline) await Task.Delay(10, TestCt); // drain times out + client.IsConnected.ShouldBeFalse(); + } + + [Fact] + public async Task Connected_sequence_is_assigned_inside_the_gate() + { + await using var server = new FakeEipServer(); + server.Handler = req => FakeEipServer.UnitReply(req, FakeEipServer.RequestCip(req)); // echo seq + cip + await using var client = await Connect(server); + + var calls = Enumerable.Range(1, 20) + .Select(i => client.SendUnitDataAsync(0xAABBCCDD, new[] { (byte)i }, Generous, TestCt)) + .ToArray(); + var results = await Task.WhenAll(calls).WaitAsync(Guard, TestCt); + + var units = server.Received.Where(f => FakeEipServer.Command(f) == EncapCommand.SendUnitData).ToList(); + var sequences = units.Select(FakeEipServer.RequestSequence).ToList(); + sequences.ShouldBe(Enumerable.Range(1, 20).Select(i => (ushort)i).ToList()); // strictly increasing, no dupes + units.Select(f => BitConverter.ToUInt32(f, 36)).ShouldAllBe(id => id == 0xAABBCCDD); // A1 item connection id + for (var i = 0; i < 20; i++) results[i].ToArray().ShouldBe(new[] { (byte)(i + 1) }); // each got its own reply + server.MaxOutstanding.ShouldBe(1); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(1, 2)] + [InlineData(65534, 65535)] + [InlineData(65535, 1)] // wraps past 0, which is never put on the wire + public void Connected_sequence_wraps_65535_to_1(int current, int next) => + EncapsulationClient.NextSequence((ushort)current).ShouldBe((ushort)next); + + [Fact] + public async Task Reply_with_foreign_context_is_skipped() + { + await using var server = new FakeEipServer(); + byte[] good = [0xCC, 0, 0, 0, 0x01]; + server.Handler = req => + [.. FakeEipServer.RRReplyWithContext(req, 0xDEADBEEF, [0xCC, 0, 0, 0, 0xEE]), .. FakeEipServer.RRReply(req, good)]; + await using var client = await Connect(server); + + (await client.SendRRDataAsync(new byte[] { 0x4C }, Generous, TestCt)).ToArray().ShouldBe(good); + client.IsConnected.ShouldBeTrue(); + } + + [Fact] + public async Task Connected_reply_with_foreign_sequence_is_skipped() + { + await using var server = new FakeEipServer(); + byte[] good = [0xCC, 0, 0, 0, 0x01]; + server.Handler = req => + [.. FakeEipServer.UnitReplyWithSequence(req, 999, [0xCC, 0, 0, 0, 0xEE]), .. FakeEipServer.UnitReply(req, good)]; + await using var client = await Connect(server); + + (await client.SendUnitDataAsync(1, new byte[] { 0x4C }, Generous, TestCt)).ToArray().ShouldBe(good); + client.IsConnected.ShouldBeTrue(); + } + + [Fact] + public async Task Dropped_connection_is_BadConnection_and_faults() + { + await using var server = new FakeEipServer { DropOnNextRequest = true }; + await using var client = await Connect(server); + + (await Should.ThrowAsync( + client.SendRRDataAsync(new byte[] { 0x4C }, Generous, TestCt).WaitAsync(Guard, TestCt))) + .Status.ShouldBe(AbStatus.BadConnection); + client.IsConnected.ShouldBeFalse(); + } + + [Fact] + public async Task Nonzero_encapsulation_status_in_the_reply_is_BadReply_and_faults() + { + await using var server = new FakeEipServer(); + server.Handler = req => + { + var r = FakeEipServer.RRReply(req, [0xCC, 0, 0, 0]); + r[8] = 0x64; // status 0x64: invalid session handle + return r; + }; + await using var client = await Connect(server); + + (await Should.ThrowAsync(client.SendRRDataAsync(new byte[] { 0x4C }, Generous, TestCt))) + .Status.ShouldBe(AbStatus.BadReply); + client.IsConnected.ShouldBeFalse(); + } + + [Fact] + public async Task Dispose_sends_UnRegisterSession() + { + await using var server = new FakeEipServer(); + var client = await Connect(server); + await client.DisposeAsync(); + + var frame = await server.WaitForFrameAsync(f => FakeEipServer.Command(f) == EncapCommand.UnRegisterSession, Guard); + BinaryPrimitives.ReadUInt32LittleEndian(frame.AsSpan(4)).ShouldBe(FakeEipServer.Session); + client.IsConnected.ShouldBeFalse(); + (await Should.ThrowAsync(client.SendRRDataAsync(new byte[] { 0x4C }, Generous, TestCt))) + .Status.ShouldBe(AbStatus.BadConnection); + await client.DisposeAsync(); // idempotent + } + + [Fact] + public async Task Connect_to_a_closed_port_is_BadConnection() + { + var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + + (await Should.ThrowAsync( + EncapsulationClient.ConnectAsync("127.0.0.1", port, Generous, TestCt))) + .Status.ShouldBe(AbStatus.BadConnection); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationCodecTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationCodecTests.cs new file mode 100644 index 00000000..174b99e7 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationCodecTests.cs @@ -0,0 +1,150 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Eip; + +public sealed class EncapsulationCodecTests +{ + private const ulong Ctx = 0x0807060504030201; + + [Fact] + public void RegisterSession_frame_is_byte_exact() => + Convert.ToHexString(EncapsulationCodec.RegisterSession()).ShouldBe( + "6500" + "0400" + "00000000" + "00000000" + "0000000000000000" + "00000000" + "0100" + "0000"); + + [Fact] + public void UnRegisterSession_frame_is_header_only() => + Convert.ToHexString(EncapsulationCodec.UnRegisterSession(0x11223344, Ctx)).ShouldBe( + "6600" + "0000" + "44332211" + "00000000" + "0102030405060708" + "00000000"); + + [Fact] + public void SendRRData_wraps_cip_in_null_address_and_unconnected_data_items() + { + var frame = EncapsulationCodec.SendRRData(0x11223344, Ctx, new byte[] { 0x4C, 0x02 }, timeoutSeconds: 10); + Convert.ToHexString(frame).ShouldBe( + "6F00" + "1200" + "44332211" + "00000000" + "0102030405060708" + "00000000" + + "00000000" + "0A00" + "0200" + "00000000" + "B2000200" + "4C02"); + } + + [Fact] + public void SendUnitData_wraps_cip_with_connection_id_and_sequence() + { + var frame = EncapsulationCodec.SendUnitData(0x11223344, Ctx, connectionId: 0xAABBCCDD, sequence: 7, new byte[] { 0x4C, 0x02 }); + Convert.ToHexString(frame).ShouldBe( + "7000" + "1800" + "44332211" + "00000000" + "0102030405060708" + "00000000" + + "00000000" + "0000" + "0200" + "A1000400" + "DDCCBBAA" + "B1000400" + "0700" + "4C02"); + } + + [Fact] + public void Oversized_cip_payload_is_refused_not_truncated() => + Should.Throw(() => + EncapsulationCodec.SendRRData(1, Ctx, new byte[EncapsulationCodec.MaxCipPayload + 1], 1)) + .Status.ShouldBe(AbStatus.TooLarge); + + [Fact] + public void RegisterSession_matches_libplctag_capture_byte_for_byte() + { + // Including the sender context: ab_server rejects a non-zero RegisterSession context with + // encapsulation status 1 (found by Task 17 controllogix — every native session failed). + var captured = CaptureFile.Load("logix-register-session.hex").First(f => f.FromClient).Bytes; + Convert.ToHexString(EncapsulationCodec.RegisterSession()).ShouldBe(Convert.ToHexString(captured)); + } + + [Fact] + public void SendRRData_matches_libplctag_forward_open_capture() + { + // libplctag put a 1-second timeout in the SendRRData timeout field; the session handle and + // context are copied from the capture so the whole frame must match byte for byte. + var captured = CaptureFile.Load("logix-forward-open.hex").First(f => f.FromClient).Bytes; + var h = EncapsulationCodec.ReadHeader(captured); + var ours = EncapsulationCodec.SendRRData(h.SessionHandle, h.SenderContext, + CaptureFile.CipPayload(new CapturedFrame(true, captured)), timeoutSeconds: 1); + Convert.ToHexString(ours).ShouldBe(Convert.ToHexString(captured)); + } + + [Fact] + public void SendUnitData_matches_libplctag_read_capture_except_timeout_field() + { + // libplctag writes 1 into the SendUnitData interface-timeout field; the CIP spec says the field + // is unused for SendUnitData and the brief pins ours to 0 — so bytes 28-29 are excluded and + // asserted separately. Everything else (connection id, sequence, CIP) must match exactly. + var captured = CaptureFile.Load("logix-read-dint.hex").First(f => f.FromClient && f.Bytes[0] == 0x70).Bytes; + var h = EncapsulationCodec.ReadHeader(captured); + var parsed = EncapsulationCodec.ParseReply(captured, expectConnected: true); + var connId = BitConverter.ToUInt32(captured, 24 + 8 + 4); // header, iface+timeout+count, A1 type+len + var ours = EncapsulationCodec.SendUnitData(h.SessionHandle, h.SenderContext, connId, parsed.Sequence, parsed.Cip.Span); + + ours.Length.ShouldBe(captured.Length); + ours.AsSpan(0, 28).ToArray().ShouldBe(captured.AsSpan(0, 28).ToArray()); + ours.AsSpan(30).ToArray().ShouldBe(captured.AsSpan(30).ToArray()); + ours.AsSpan(28, 2).ToArray().ShouldBe(new byte[] { 0, 0 }); + connId.ShouldBe(0x2CDF633Fu); + parsed.Sequence.ShouldBe((ushort)1); + } + + [Fact] + public void ParseReply_of_captured_connected_read_strips_the_sequence_count() + { + var reply = CaptureFile.Load("logix-read-dint.hex").First(f => !f.FromClient && f.Bytes[0] == 0x70).Bytes; + var (h, cip, seq) = EncapsulationCodec.ParseReply(reply, expectConnected: true); + h.Command.ShouldBe(EncapCommand.SendUnitData); + h.SessionHandle.ShouldBe(0x1A08A5F3u); + seq.ShouldBe((ushort)1); + Convert.ToHexString(cip.ToArray()).ShouldBe("D2000000C40040E20100"); // Read Tag reply, DINT 123456 + } + + [Fact] + public void ParseReply_of_captured_unconnected_forward_open_returns_the_cip_reply() + { + var reply = CaptureFile.Load("logix-forward-open.hex").First(f => !f.FromClient).Bytes; + var (h, cip, seq) = EncapsulationCodec.ParseReply(reply, expectConnected: false); + h.Command.ShouldBe(EncapCommand.SendRRData); + seq.ShouldBe((ushort)0); + cip.Length.ShouldBe(0x1E); + cip.Span[0].ShouldBe((byte)0xDB); // Large Forward Open reply + } + + [Fact] + public void ParseReply_of_register_session_returns_header_and_empty_payload() + { + var reply = CaptureFile.Load("logix-register-session.hex").First(f => !f.FromClient).Bytes; + var (h, cip, _) = EncapsulationCodec.ParseReply(reply, expectConnected: false); + h.SessionHandle.ShouldBe(0x1A08A5F3u); + cip.IsEmpty.ShouldBeTrue(); + } + + [Fact] + public void Nonzero_encapsulation_status_is_BadReply() + { + var reply = Convert.FromHexString("6F00" + "0000" + "44332211" + "01000000" + "0102030405060708" + "00000000"); + Should.Throw(() => EncapsulationCodec.ParseReply(reply, expectConnected: false)) + .Status.ShouldBe(AbStatus.BadReply); + } + + [Fact] + public void Length_mismatch_is_BadReply() + { + var frame = EncapsulationCodec.SendRRData(1, Ctx, new byte[] { 0xCC, 0 }, 0); + Should.Throw(() => EncapsulationCodec.ParseReply(frame.AsMemory(0, frame.Length - 1), false)) + .Status.ShouldBe(AbStatus.BadReply); + } + + [Fact] + public void Missing_expected_data_item_is_BadReply() + { + // An unconnected (0xB2) reply parsed as connected has no 0xB1 item. + var frame = EncapsulationCodec.SendRRData(1, Ctx, new byte[] { 0xCC, 0 }, 0); + Should.Throw(() => EncapsulationCodec.ParseReply(frame, expectConnected: true)) + .Status.ShouldBe(AbStatus.BadReply); + } + + [Fact] + public void Item_length_running_past_the_frame_is_BadReply() + { + var frame = EncapsulationCodec.SendRRData(1, Ctx, new byte[] { 0xCC, 0 }, 0); + frame[24 + 8 + 4 + 2] = 0x40; // B2 item length 2 -> 64 + Should.Throw(() => EncapsulationCodec.ParseReply(frame, expectConnected: false)) + .Status.ShouldBe(AbStatus.BadReply); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipChannel.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipChannel.cs new file mode 100644 index 00000000..7e0179ac --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipChannel.cs @@ -0,0 +1,128 @@ +using System.Buffers.Binary; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; + +/// +/// Scriptable : settable , records every request +/// and the token it was sent with, and answers via . The default responder +/// answers any service with an empty success reply and splits a Multiple Service Packet into +/// per-service replies via . Reused by Tasks 12 and 13. +/// +internal sealed class FakeCipChannel : ICipChannel +{ + private readonly SemaphoreSlim _entered = new(0); + + public FakeCipChannel(int maxRequestBytes = 504) + { + MaxRequestBytes = maxRequestBytes; + Respond = DefaultRespond; + } + + private int _maxRequestBytes; + + public int MaxRequestBytes + { + get => MaxRequestBytesThrows is { } ex ? throw ex : _maxRequestBytes; + set => _maxRequestBytes = value; + } + + /// When set, reading throws it (simulates a broken channel). + public Exception? MaxRequestBytesThrows { get; set; } + + /// Every request, in send order. + public List Sent { get; } = []; + + /// The token each request was sent with, parallel to . + public List Tokens { get; } = []; + + /// Answers one request with raw CIP reply bytes. + public Func> Respond { get; set; } + + /// Answers one embedded (non-MSP) service; used by the default . + public Func RespondPerService { get; set; } = OkReply; + + /// When set, every send blocks on it (after being recorded) until the test completes it. + public TaskCompletionSource? Gate { get; set; } + + public int SentCount { get { lock (Sent) return Sent.Count; } } + + public async Task> SendAsync(ReadOnlyMemory cipRequest, CancellationToken cancellationToken) + { + var req = cipRequest.ToArray(); + lock (Sent) + { + Sent.Add(req); + Tokens.Add(cancellationToken); + } + _entered.Release(); + if (Gate is { } g) await g.Task.WaitAsync(cancellationToken); + return await Respond(req); + } + + /// Completes once the channel has received requests in total. + public async Task WaitForSendsAsync(int count) + { + while (SentCount < count) + { + if (!await _entered.WaitAsync(TimeSpan.FromSeconds(10))) + throw new TimeoutException($"Expected {count} sends, saw {SentCount}."); + } + } + + /// A success reply for with optional data. + public static byte[] OkReply(byte[] request) => Reply(request[0], 0x00, []); + + public static byte[] Reply(byte requestService, byte generalStatus, byte[] data, params ushort[] extended) + { + var r = new byte[4 + 2 * extended.Length + data.Length]; + r[0] = (byte)(requestService | 0x80); + r[2] = generalStatus; + r[3] = (byte)extended.Length; + for (var i = 0; i < extended.Length; i++) BinaryPrimitives.WriteUInt16LittleEndian(r.AsSpan(4 + 2 * i), extended[i]); + data.CopyTo(r, 4 + 2 * extended.Length); + return r; + } + + /// Splits an MSP request into its embedded requests. + public static List SplitMsp(byte[] msp) + { + const int countAt = 6; // 0A 02 20 02 24 01 + var data = msp.AsSpan(countAt); + int n = BinaryPrimitives.ReadUInt16LittleEndian(data); + var offsets = new int[n]; + for (var i = 0; i < n; i++) offsets[i] = BinaryPrimitives.ReadUInt16LittleEndian(data[(2 + 2 * i)..]); + var result = new List(n); + for (var i = 0; i < n; i++) + { + var end = i + 1 < n ? offsets[i + 1] : data.Length; + result.Add(data[offsets[i]..end].ToArray()); + } + return result; + } + + /// An MSP reply (service 0x8A) carrying the given embedded replies. + public static byte[] MspReply(IReadOnlyList replies, byte generalStatus = 0x00) + { + var body = new List(); + var header = new byte[2 + 2 * replies.Count]; + BinaryPrimitives.WriteUInt16LittleEndian(header, (ushort)replies.Count); + var offset = header.Length; + for (var i = 0; i < replies.Count; i++) + { + BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(2 + 2 * i), (ushort)offset); + offset += replies[i].Length; + } + body.AddRange(header); + foreach (var r in replies) body.AddRange(r); + return Reply(0x0A, generalStatus, [.. body]); + } + + /// The default responder: per-service replies, split and re-packed for an MSP. + public Task DefaultRespond(byte[] request) + { + if (request[0] != 0x0A) return Task.FromResult(RespondPerService(request)); + var replies = SplitMsp(request).Select(RespondPerService).ToList(); + var anyFailed = replies.Any(r => r[2] != 0); + return Task.FromResult(MspReply(replies, anyFailed ? (byte)0x1E : (byte)0x00)); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipTarget.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipTarget.cs new file mode 100644 index 00000000..4341a66c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipTarget.cs @@ -0,0 +1,131 @@ +using System.Buffers.Binary; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; + +/// +/// A CIP target on top of : answers Forward Open / Forward Close / +/// Unconnected Send / Direct (bare SendRRData) / connected SendUnitData requests and counts each kind. +/// +internal sealed class FakeCipTarget : IAsyncDisposable +{ + public const uint OTConnectionId = 0xC0FFEE01; + + private readonly List _forwardOpens = []; + private readonly List _forwardCloses = []; + private readonly List _unconnectedEmbedded = []; + private readonly List _direct = []; + private readonly List _connected = []; + + public FakeCipTarget() + { + Server.HandlerAsync = HandleAsync; + } + + public FakeEipServer Server { get; } = new(); + + public int Port => Server.Port; + + /// Forward Open request CIP → reply CIP. Defaults to . + public Func ForwardOpenReply { get; set; } = Accept; + + /// Reply to an ordinary service request (Direct, embedded in Unconnected Send, or connected). + /// Defaults to success with two data bytes (a Vendor ID, for the keep-alive). + public Func ServiceReply { get; set; } = req => [(byte)(req[0] | 0x80), 0, 0, 0, 0x01, 0x00]; + + /// Connected requests only: may delay, or return null to never answer. Defaults to . + public Func>? ConnectedHandler { get; set; } + + /// Awaited before a Forward Open is answered (the request is already counted). + public Func? ForwardOpenGate { get; set; } + + /// Awaited before a Forward Close is answered (the request is already counted). + public Func? ForwardCloseGate { get; set; } + + public int ForwardOpens { get { lock (_forwardOpens) return _forwardOpens.Count; } } + public int ForwardCloses { get { lock (_forwardCloses) return _forwardCloses.Count; } } + public int UnconnectedSends { get { lock (_unconnectedEmbedded) return _unconnectedEmbedded.Count; } } + public int DirectRequests { get { lock (_direct) return _direct.Count; } } + public int ConnectedRequests { get { lock (_connected) return _connected.Count; } } + + public IReadOnlyList ForwardOpenRequests { get { lock (_forwardOpens) return [.. _forwardOpens]; } } + public IReadOnlyList ForwardCloseRequests { get { lock (_forwardCloses) return [.. _forwardCloses]; } } + public IReadOnlyList UnconnectedEmbedded { get { lock (_unconnectedEmbedded) return [.. _unconnectedEmbedded]; } } + public IReadOnlyList DirectCips { get { lock (_direct) return [.. _direct]; } } + public IReadOnlyList ConnectedCips { get { lock (_connected) return [.. _connected]; } } + + /// A successful (Large) Forward Open reply echoing the request's ids and serials. + public static byte[] Accept(byte[] fo) => new CipWriter() + .U8((byte)(fo[0] | 0x80)).U8(0).U8(0).U8(0) + .U32(OTConnectionId) + .U32(BinaryPrimitives.ReadUInt32LittleEndian(fo.AsSpan(12))) + .U16(BinaryPrimitives.ReadUInt16LittleEndian(fo.AsSpan(16))) + .U16(BinaryPrimitives.ReadUInt16LittleEndian(fo.AsSpan(18))) + .U32(BinaryPrimitives.ReadUInt32LittleEndian(fo.AsSpan(20))) + .U32(2_000_000).U32(2_000_000).U8(0).U8(0) + .ToArray(); + + /// A refusal with the given general and extended status. + public static Func Refuse(byte general, params ushort[] extended) => fo => + { + var w = new CipWriter().U8((byte)(fo[0] | 0x80)).U8(0).U8(general).U8((byte)extended.Length); + foreach (var e in extended) w.U16(e); + return w.ToArray(); + }; + + public static bool IsForwardOpen(byte[] cip) => cip[0] is 0x54 or 0x5B && AddressesConnectionManager(cip); + + public static bool IsForwardClose(byte[] cip) => cip[0] == 0x4E && AddressesConnectionManager(cip); + + private static bool AddressesConnectionManager(byte[] cip) => + cip.Length >= 6 && cip.AsSpan(2, 4).SequenceEqual(CipPath.ConnectionManager); + + private async Task HandleAsync(byte[] frame) + { + var cip = FakeEipServer.RequestCip(frame); + if (FakeEipServer.Command(frame) == EncapCommand.SendUnitData) + { + lock (_connected) _connected.Add(cip); + var reply = ConnectedHandler is null ? ServiceReply(cip) : await ConnectedHandler(cip); + return reply is null ? null : FakeEipServer.UnitReply(frame, reply); + } + + if (IsForwardOpen(cip)) + { + lock (_forwardOpens) _forwardOpens.Add(cip); + if (ForwardOpenGate is { } openGate) await openGate(); + return FakeEipServer.RRReply(frame, ForwardOpenReply(cip)); + } + if (IsForwardClose(cip)) + { + lock (_forwardCloses) _forwardCloses.Add(cip); + if (ForwardCloseGate is { } closeGate) await closeGate(); + var w = new CipWriter().U8(0xCE).U8(0).U8(0).U8(0).Bytes(cip.AsSpan(8, 8)).U8(0).U8(0); + return FakeEipServer.RRReply(frame, w.ToArray()); + } + if (cip[0] == 0x52 && AddressesConnectionManager(cip)) + { + var len = BinaryPrimitives.ReadUInt16LittleEndian(cip.AsSpan(8)); + var embedded = cip.AsSpan(10, len).ToArray(); + lock (_unconnectedEmbedded) _unconnectedEmbedded.Add(embedded); + return FakeEipServer.RRReply(frame, ServiceReply(embedded)); + } + + lock (_direct) _direct.Add(cip); + return FakeEipServer.RRReply(frame, ServiceReply(cip)); + } + + /// Polls (real time) until holds. + public static async Task Until(Func condition, TimeSpan timeout, string what) + { + var giveUp = DateTime.UtcNow + timeout; + while (!condition()) + { + if (DateTime.UtcNow > giveUp) throw new TimeoutException($"Timed out waiting for {what}."); + await Task.Delay(5); + } + } + + public ValueTask DisposeAsync() => Server.DisposeAsync(); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeEipServer.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeEipServer.cs new file mode 100644 index 00000000..79bf33bf --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeEipServer.cs @@ -0,0 +1,251 @@ +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Threading.Channels; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; + +/// +/// Scriptable loopback EtherNet/IP endpoint. RegisterSession is answered automatically (session +/// ) and UnRegisterSession is recorded but never answered. Every other frame is +/// answered by the next queued step, else by ; a +/// non-null result is written back verbatim (it may hold several concatenated frames). +/// +/// +/// Each connection runs a reader loop and a responder loop, so frames are recorded the moment they +/// arrive — even while a reply is still pending. therefore really does +/// see a client that pipelines a second request before the first reply. +/// +internal sealed class FakeEipServer : IAsyncDisposable +{ + public const uint Session = 0x11223344; + + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _cts = new(); + private readonly ConcurrentQueue>> _script = new(); + private readonly List _received = []; + private readonly List<(Predicate Match, TaskCompletionSource Tcs)> _waiters = []; + private readonly ConcurrentBag _clients = []; + private readonly ConcurrentBag _handlerErrors = []; + private int _connections; + private int _outstanding; + private int _maxOutstanding; + + public FakeEipServer() + { + _listener.Start(); + _ = AcceptLoop(); + } + + public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + + /// Every frame received, in arrival order (snapshot). + public IReadOnlyList Received { get { lock (_received) return [.. _received]; } } + + public int Connections => Volatile.Read(ref _connections); + + /// Largest number of non-session requests ever received but not yet answered. + public int MaxOutstanding => Volatile.Read(ref _maxOutstanding); + + /// Exceptions thrown by handlers / script steps (a test assertion inside a handler lands here). + public IReadOnlyCollection HandlerErrors => _handlerErrors; + + /// Default responder, used once the script is exhausted. + public Func> HandlerAsync { get; set; } = _ => Task.FromResult(null); + + /// Synchronous convenience setter for . + public Func Handler { set => HandlerAsync = req => Task.FromResult(value(req)); } + + /// Never answer non-session requests. + public bool Stall { get; set; } + + /// Zero the sender context (header bytes 12-19) of every reply frame, as ab_server does. + public bool ZeroReplyContext { get; set; } + + /// Close the connection instead of answering the next non-session request. + public bool DropOnNextRequest { get; set; } + + /// Queue one-shot responders, consumed one per non-session request. + public void Script(params Func[] steps) + { + foreach (var s in steps) _script.Enqueue(req => Task.FromResult(s(req))); + } + + /// Queue one-shot asynchronous responders (e.g. one that waits for the test to release it). + public void ScriptAsync(params Func>[] steps) + { + foreach (var s in steps) _script.Enqueue(s); + } + + /// Completes with the first received frame (past or future) matching . + public Task WaitForFrameAsync(Predicate match, TimeSpan timeout) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + lock (_received) + { + var hit = _received.FirstOrDefault(f => match(f)); + if (hit is not null) return Task.FromResult(hit); + _waiters.Add((match, tcs)); + } + return tcs.Task.WaitAsync(timeout); + } + + /// Completes once frames with encapsulation command have arrived. + public async Task WaitForCommandCountAsync(ushort command, int count, TimeSpan timeout) + { + var seen = 0; + await WaitForFrameAsync(f => Command(f) == command && ++seen >= count, timeout); + } + + public static ushort Command(byte[] frame) => BinaryPrimitives.ReadUInt16LittleEndian(frame); + + private async Task AcceptLoop() + { + while (!_cts.IsCancellationRequested) + { + TcpClient c; + try { c = await _listener.AcceptTcpClientAsync(_cts.Token); } + catch { return; } + Interlocked.Increment(ref _connections); + _clients.Add(c); + _ = Serve(c); + } + } + + private async Task Serve(TcpClient c) + { + using var _ = c; + var s = c.GetStream(); + var inbox = Channel.CreateUnbounded(); + var responder = Respond(c, s, inbox.Reader); + var head = new byte[EncapsulationHeader.Size]; + while (!_cts.IsCancellationRequested) + { + if (!await ReadExact(s, head)) break; + var len = head[2] | (head[3] << 8); + var frame = new byte[EncapsulationHeader.Size + len]; + head.CopyTo(frame, 0); + if (!await ReadExact(s, frame.AsMemory(EncapsulationHeader.Size))) break; + if (Command(frame) is not (EncapCommand.RegisterSession or EncapCommand.UnRegisterSession)) + { + var now = Interlocked.Increment(ref _outstanding); + int max; + while (now > (max = Volatile.Read(ref _maxOutstanding)) && + Interlocked.CompareExchange(ref _maxOutstanding, now, max) != max) { } + } + Record(frame); + inbox.Writer.TryWrite(frame); + } + inbox.Writer.TryComplete(); + try { await responder; } catch { /* connection gone */ } + } + + private async Task Respond(TcpClient c, NetworkStream s, ChannelReader inbox) + { + await foreach (var frame in inbox.ReadAllAsync(_cts.Token)) + { + byte[]? reply; + switch (Command(frame)) + { + case EncapCommand.RegisterSession: + reply = (byte[])frame.Clone(); + BinaryPrimitives.WriteUInt32LittleEndian(reply.AsSpan(4), Session); + break; + case EncapCommand.UnRegisterSession: + reply = null; + break; + default: + if (DropOnNextRequest) + { + DropOnNextRequest = false; + c.Close(); + return; + } + if (Stall) continue; // stays outstanding forever + var step = _script.TryDequeue(out var scripted) ? scripted : HandlerAsync; + try { reply = await step(frame); } + catch (Exception ex) { _handlerErrors.Add(ex); reply = null; } + Interlocked.Decrement(ref _outstanding); // before the write: the client cannot send + break; // its next request until it has this reply + } + if (reply is null) continue; + if (ZeroReplyContext) ZeroContexts(reply); + await s.WriteAsync(reply, _cts.Token); + } + } + + private void Record(byte[] frame) + { + List> fire = []; + lock (_received) + { + _received.Add(frame); + for (var i = _waiters.Count - 1; i >= 0; i--) + { + if (!_waiters[i].Match(frame)) continue; + fire.Add(_waiters[i].Tcs); + _waiters.RemoveAt(i); + } + } + foreach (var t in fire) t.TrySetResult(frame); + } + + private static void ZeroContexts(byte[] buffer) + { + for (var off = 0; off + EncapsulationHeader.Size <= buffer.Length; + off += EncapsulationHeader.Size + BinaryPrimitives.ReadUInt16LittleEndian(buffer.AsSpan(off + 2))) + buffer.AsSpan(off + 12, 8).Clear(); + } + + private async Task ReadExact(NetworkStream s, Memory buf) + { + var got = 0; + while (got < buf.Length) + { + int n; + try { n = await s.ReadAsync(buf[got..], _cts.Token); } + catch { return false; } + if (n == 0) return false; + got += n; + } + return true; + } + + // ---- reply builders ------------------------------------------------------------------------- + + /// The CIP payload of a SendRRData / SendUnitData request. + public static byte[] RequestCip(byte[] request) => + EncapsulationCodec.ParseReply(request, expectConnected: Command(request) == EncapCommand.SendUnitData).Cip.ToArray(); + + /// The connected sequence count of a SendUnitData request. + public static ushort RequestSequence(byte[] request) => + EncapsulationCodec.ParseReply(request, expectConnected: true).Sequence; + + /// A SendRRData reply echoing the request's session + context, carrying . + public static byte[] RRReply(byte[] request, byte[] cipReply) => + RRReplyWithContext(request, BinaryPrimitives.ReadUInt64LittleEndian(request.AsSpan(12)), cipReply); + + /// A SendRRData reply with an explicit sender context. + public static byte[] RRReplyWithContext(byte[] request, ulong ctx, byte[] cipReply) => + EncapsulationCodec.SendRRData(BinaryPrimitives.ReadUInt32LittleEndian(request.AsSpan(4)), ctx, cipReply, 0); + + /// A SendUnitData reply echoing the request's sequence count, carrying . + public static byte[] UnitReply(byte[] request, byte[] cipReply) => + UnitReplyWithSequence(request, RequestSequence(request), cipReply); + + /// A SendUnitData reply with an explicit sequence count (T→O connection id 0x5A5A5A5A). + public static byte[] UnitReplyWithSequence(byte[] request, ushort sequence, byte[] cipReply) => + EncapsulationCodec.SendUnitData(BinaryPrimitives.ReadUInt32LittleEndian(request.AsSpan(4)), + BinaryPrimitives.ReadUInt64LittleEndian(request.AsSpan(12)), 0x5A5A5A5A, sequence, cipReply); + + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + _listener.Stop(); + foreach (var c in _clients) + { + try { c.Dispose(); } catch { /* already gone */ } + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/ManualTimeProvider.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/ManualTimeProvider.cs new file mode 100644 index 00000000..f7c940c7 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/ManualTimeProvider.cs @@ -0,0 +1,106 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; + +/// +/// Deterministic : time moves only on , which fires every +/// timer falling due on the way, in due order, synchronously on the calling thread. +/// (Microsoft.Extensions.TimeProvider.Testing is not pinned in this repo and no package may be added.) +/// +internal sealed class ManualTimeProvider : TimeProvider +{ + private readonly object _lock = new(); + private readonly List _timers = []; + private DateTimeOffset _now; + + public ManualTimeProvider() => Start = _now = new DateTimeOffset(2026, 10, 7, 0, 0, 0, TimeSpan.Zero); + + public DateTimeOffset Start { get; } + + /// Steps the wall clock () without moving monotonic time + /// () or any timer, as an NTP or operator clock change does. + public TimeSpan WallClockOffset { get; set; } + + public override DateTimeOffset GetUtcNow() { lock (_lock) return _now + WallClockOffset; } + + public override long GetTimestamp() { lock (_lock) return _now.UtcTicks; } + + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var t = new ManualTimer(this, callback, state); + lock (_lock) _timers.Add(t); + t.Change(dueTime, period); + return t; + } + + /// Armed timers (not disposed, with a due time). + public int ArmedTimers { get { lock (_lock) return _timers.Count(t => t.Due is not null); } } + + public bool HasTimerDueAt(DateTimeOffset at) { lock (_lock) return _timers.Any(t => t.Due == at); } + + /// Waits (in real time) until some code under test has armed a timer due at , + /// i.e. is genuinely parked on the fake clock rather than merely not yet scheduled. + public async Task WaitForTimerDueAtAsync(DateTimeOffset at, TimeSpan timeout) + { + var giveUp = DateTime.UtcNow + timeout; + while (!HasTimerDueAt(at)) + { + if (DateTime.UtcNow > giveUp) throw new TimeoutException($"No timer armed for {at:O} (now {GetUtcNow():O})."); + await Task.Delay(5); + } + } + + public void Advance(TimeSpan by) + { + DateTimeOffset target; + lock (_lock) target = _now + by; + while (true) + { + ManualTimer? next; + lock (_lock) + { + next = _timers.Where(t => t.Due <= target).MinBy(t => t.Due); + if (next is null) { _now = target; return; } + _now = next.Due!.Value; + next.Due = next.Period is { } p ? _now + p : null; + } + next.Fire(); + } + } + + private sealed class ManualTimer(ManualTimeProvider owner, TimerCallback callback, object? state) : ITimer + { + public DateTimeOffset? Due; // guarded by owner._lock + public TimeSpan? Period; // guarded by owner._lock + private bool _disposed; + + public void Fire() => callback(state); + + public bool Change(TimeSpan dueTime, TimeSpan period) + { + lock (owner._lock) + { + if (_disposed) return false; + Due = dueTime == Timeout.InfiniteTimeSpan ? null : owner._now + dueTime; + Period = period == Timeout.InfiniteTimeSpan || period == TimeSpan.Zero ? null : period; + return true; + } + } + + public void Dispose() + { + lock (owner._lock) + { + _disposed = true; + Due = null; + owner._timers.Remove(this); + } + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/LibplctagConfinementGuardTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/LibplctagConfinementGuardTests.cs new file mode 100644 index 00000000..1ee25c6d --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/LibplctagConfinementGuardTests.cs @@ -0,0 +1,57 @@ +using System.Text.RegularExpressions; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests; + +public sealed class LibplctagConfinementGuardTests +{ + private static readonly Regex Reference = new( + @"\busing\s+(static\s+)?(\w+\s*=\s*)?libplctag\b|\blibplctag\.[A-Z]", + RegexOptions.Compiled); + + [Fact] + public void Only_the_libplctag_adapters_reference_libplctag() + { + var root = new DirectoryInfo(AppContext.BaseDirectory); + while (root is not null && !File.Exists(Path.Combine(root.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) root = root.Parent; + root.ShouldNotBeNull("could not find the repo root"); + + var offenders = new List(); + foreach (var file in Directory.EnumerateFiles(Path.Combine(root.FullName, "src"), "*.cs", SearchOption.AllDirectories)) + { + if (file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}") || + file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")) continue; + var name = Path.GetFileName(file); + var dir = Path.GetFileName(Path.GetDirectoryName(file)!); + var allowed = name.StartsWith("Libplctag", StringComparison.Ordinal) + && dir is "ZB.MOM.WW.OtOpcUa.Driver.AbCip" or "ZB.MOM.WW.OtOpcUa.Driver.AbLegacy"; + if (allowed) continue; + var lines = File.ReadAllLines(file); + for (var i = 0; i < lines.Length; i++) + { + var code = lines[i].Split("//")[0]; + if (Reference.IsMatch(code)) offenders.Add($"{Path.GetRelativePath(root.FullName, file)}:{i + 1}: {lines[i].Trim()}"); + } + } + offenders.ShouldBeEmpty(string.Join(Environment.NewLine, offenders)); + } + + [Fact] + public void Prose_mentions_of_libplctag_do_not_trip_the_guard() + { + var prose = "via libplctag. Second of six driver CLIs"; + Reference.IsMatch(prose).ShouldBeFalse(); + } + + [Theory] + [InlineData("using libplctag;")] + [InlineData("global using libplctag;")] + [InlineData("using static libplctag.NativeImport.plctag;")] + [InlineData("using T = libplctag.Tag;")] + [InlineData("var x = new libplctag.DataTypes.DintPlcMapper();")] + public void Code_references_trip_the_guard(string code) + { + Reference.IsMatch(code).ShouldBeTrue($"regex should match: {code}"); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixServicesTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixServicesTests.cs new file mode 100644 index 00000000..7bd51dd5 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixServicesTests.cs @@ -0,0 +1,118 @@ +using System.Text; +using System.Buffers.Binary; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Logix; + +// CAPTURE DECISION: libplctag sends Read Tag FRAGMENTED (0x52, offset 0) for plain reads, connected, +// with no MSP and no Unconnected Send wrapper. The brief's "ReadTag 0x4C" comparison is therefore made +// against ReadTagFragmented(path, 1, 0). Replies are service 0xD2 (0x52|0x80). +public sealed class LogixServicesTests +{ + private static byte[] Int32Le(int v) { var a = new byte[4]; BinaryPrimitives.WriteInt32LittleEndian(a, v); return a; } + + [Fact] + public void Error_read_reply_surfaces_status_from_capture() => + Should.Throw(() => LogixServices.ParseReadReply(Rep("logix-read-unknown-tag.hex"))) + .Status.ShouldBe(AbStatus.NotFound); + + private static byte[] Req(string file, int i = 0) => + CaptureFile.CipPayload(CaptureFile.Load(file).Where(f => f.FromClient).ElementAt(i)); + + private static CipReply Rep(string file, int i = 0) => + CipReply.Parse(CaptureFile.CipPayload(CaptureFile.Load(file).Where(f => !f.FromClient).ElementAt(i))); + + [Fact] + public void Read_request_matches_capture() => + LogixServices.ReadTagFragmented(CipPath.EncodeSymbolic("TestDINT"), 1, 0).ShouldBe(Req("logix-read-dint.hex")); + + [Fact] + public void Read_array_request_matches_capture() => + LogixServices.ReadTagFragmented(CipPath.EncodeSymbolic("TestArray"), 16, 0).ShouldBe(Req("logix-read-dint-array16.hex")); + + [Fact] + public void Read_dint_value() + { + var (type, handle, data) = LogixServices.ParseReadReply(Rep("logix-read-dint.hex")); + type.ShouldBe(LogixTypeCode.DInt); + handle.ShouldBeNull(); + data.Length.ShouldBe(4); + BinaryPrimitives.ReadInt32LittleEndian(data.Span).ShouldBe(123456); + } + + [Fact] + public void Read_array_values() + { + var (type, _, data) = LogixServices.ParseReadReply(Rep("logix-read-dint-array16.hex")); + type.ShouldBe(LogixTypeCode.DInt); + data.Length.ShouldBe(64); + for (var i = 0; i < 16; i++) + BinaryPrimitives.ReadUInt32LittleEndian(data.Span[(i * 4)..]).ShouldBe((uint)(((i + 1) << 24) | (i + 1))); + } + + [Fact] + public void Read_bool_value() + { + var (type, _, data) = LogixServices.ParseReadReply(Rep("logix-read-bool.hex")); + type.ShouldBe(LogixTypeCode.Bool); + data.Length.ShouldBe(1); + data.Span[0].ShouldBe((byte)1); + } + + // ab_server types STRING as atomic D0 00 (a real Logix STRING is A0 02 + handle): simulator behaviour. + [Fact] + public void Read_string_value_from_sim() + { + var (type, handle, data) = LogixServices.ParseReadReply(Rep("logix-read-string.hex")); + type.ShouldBe((ushort)0x00D0); + handle.ShouldBeNull(); + data.Length.ShouldBe(88); + BinaryPrimitives.ReadInt32LittleEndian(data.Span).ShouldBe(7); + Encoding.ASCII.GetString(data.Span.Slice(4, 7)).ShouldBe("HelloAB"); + } + + [Fact] + public void Read_unknown_tag_reply_status() + { + var r = Rep("logix-read-unknown-tag.hex"); + r.GeneralStatus.ShouldBe((byte)0x04); + r.Status.ShouldBe(AbStatus.NotFound); + } + + [Fact] + public void Write_request_matches_capture() => + LogixServices.WriteTag(CipPath.EncodeSymbolic("TestDINT"), LogixTypeCode.DInt, null, 1, Int32Le(123456)) + .ShouldBe(Req("logix-write-dint.hex", 1)); + + [Fact] + public void Write_reply_is_ok() => Rep("logix-write-dint.hex", 1).GeneralStatus.ShouldBe((byte)0); + + [Fact] + public void Read_tag_plain_layout() => + Convert.ToHexString(LogixServices.ReadTag(CipPath.EncodeSymbolic("Abc"), 3)).ShouldBe("4C03" + "910341626300" + "0300"); + + [Fact] + public void Fragmented_write_layout() => + Convert.ToHexString(LogixServices.WriteTagFragmented(CipPath.EncodeSymbolic("Abc"), LogixTypeCode.DInt, null, 2, 8, [1, 2, 3, 4])) + .ShouldBe("5303" + "910341626300" + "C400" + "0200" + "08000000" + "01020304"); + + [Fact] + public void Struct_write_includes_handle() => + Convert.ToHexString(LogixServices.WriteTag(CipPath.EncodeSymbolic("Abc"), LogixTypeCode.Struct, 0x0FCE, 1, [9])) + .ShouldBe("4D03" + "910341626300" + "A002" + "CE0F" + "0100" + "09"); + + [Fact] + public void Struct_write_without_handle_throws() => + Should.Throw(() => LogixServices.WriteTag(CipPath.EncodeSymbolic("Abc"), LogixTypeCode.Struct, null, 1, [9])); + + [Fact] + public void Read_reply_with_struct_handle() + { + var reply = CipReply.Parse(Convert.FromHexString("CC000000" + "A002" + "CE0F" + "05000000" + "48656C6C6F")); + var (type, handle, data) = LogixServices.ParseReadReply(reply); + type.ShouldBe((ushort)0x02A0); handle.ShouldBe((ushort)0x0FCE); data.Length.ShouldBe(9); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTagClientTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTagClientTests.cs new file mode 100644 index 00000000..1faee861 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTagClientTests.cs @@ -0,0 +1,282 @@ +using System.Buffers.Binary; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Logix; + +public sealed class LogixTagClientTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + private static readonly byte[] Path = CipPath.EncodeSymbolic("TestArray"); + + /// Offset field of a Read/Write Tag Fragmented request (after service, size, path, type?, elements). + private static uint ReadOffset(byte[] req) => BinaryPrimitives.ReadUInt32LittleEndian(req.AsSpan(2 + Path.Length + 2)); + + private static byte[] Bytes(int count, int seed) => Enumerable.Range(0, count).Select(i => (byte)(seed + i)).ToArray(); + + private static byte[] TypedData(ushort type, byte[] data, ushort? handle = null) + { + var header = new List { (byte)type, (byte)(type >> 8) }; + if (handle is { } h) header.AddRange([(byte)h, (byte)(h >> 8)]); + return [.. header, .. data]; + } + + private static (FakeCipChannel Channel, LogixTagClient Client, RequestScheduler Scheduler) Create(int max = 504) + { + var ch = new FakeCipChannel(max); + var s = new RequestScheduler(ch, allowPacking: true); + return (ch, new LogixTagClient(s, ch), s); + } + + [Fact] + public async Task A_read_that_fits_is_one_request_at_offset_zero() + { + var (ch, client, s) = Create(); + await using var _ = s; + ch.RespondPerService = r => FakeCipChannel.Reply(r[0], 0x00, TypedData(LogixTypeCode.DInt, [0x87, 0xD6, 0x12, 0x00])); + + var (type, handle, data) = await client.ReadAsync(Path, 1, Ct); + + type.ShouldBe(LogixTypeCode.DInt); + handle.ShouldBeNull(); + data.ShouldBe(new byte[] { 0x87, 0xD6, 0x12, 0x00 }); + ch.Sent.Count.ShouldBe(1); + ch.Sent[0].ShouldBe(LogixServices.ReadTagFragmented(Path, 1, 0)); // libplctag's wire shape (golden captures) + } + + [Fact] + public async Task Fragmented_read_keeps_the_first_partial_and_continues() + { + var (ch, client, s) = Create(); + await using var _ = s; + ch.RespondPerService = r => ReadOffset(r) switch + { + 0 => FakeCipChannel.Reply(r[0], 0x06, TypedData(LogixTypeCode.DInt, Bytes(100, 0))), + 100 => FakeCipChannel.Reply(r[0], 0x06, TypedData(LogixTypeCode.DInt, Bytes(100, 100))), + 200 => FakeCipChannel.Reply(r[0], 0x00, TypedData(LogixTypeCode.DInt, Bytes(40, 200))), + var o => throw new InvalidOperationException($"unexpected offset {o}"), + }; + + var (type, _, data) = await client.ReadAsync(Path, 60, Ct); + + type.ShouldBe(LogixTypeCode.DInt); + data.Length.ShouldBe(240); + data.ShouldBe(Bytes(240, 0)); // first chunk kept once, type headers stripped, in order + ch.Sent.Select(ReadOffset).ShouldBe(new uint[] { 0, 100, 200 }); + ch.Sent.ShouldAllBe(r => r[0] == 0x52); + } + + [Fact] + public async Task A_fragmented_struct_read_keeps_the_handle_from_the_first_reply() + { + var (ch, client, s) = Create(); + await using var _ = s; + ch.RespondPerService = r => ReadOffset(r) == 0 + ? FakeCipChannel.Reply(r[0], 0x06, TypedData(LogixTypeCode.Struct, Bytes(8, 0), handle: 0x0FCE)) + : FakeCipChannel.Reply(r[0], 0x00, TypedData(LogixTypeCode.Struct, Bytes(4, 8), handle: 0x0FCE)); + + var (type, handle, data) = await client.ReadAsync(Path, 1, Ct); + + type.ShouldBe(LogixTypeCode.Struct); + handle.ShouldBe((ushort)0x0FCE); + data.ShouldBe(Bytes(12, 0)); + } + + [Fact] + public async Task A_failed_read_throws_with_the_reply_status() + { + var (ch, client, s) = Create(); + await using var _ = s; + ch.RespondPerService = r => FakeCipChannel.Reply(r[0], 0x04, [], 0x0000); + + var ex = await Should.ThrowAsync(() => client.ReadAsync(Path, 1, Ct)); + ex.Status.ShouldBe(AbStatusCodes.FromCip(0x04, [0x0000])); + } + + [Fact] + public async Task A_fragment_failing_mid_read_throws_with_its_status() + { + var (ch, client, s) = Create(); + await using var _ = s; + ch.RespondPerService = r => ReadOffset(r) == 0 + ? FakeCipChannel.Reply(r[0], 0x06, TypedData(LogixTypeCode.DInt, Bytes(100, 0))) + : FakeCipChannel.Reply(r[0], 0x05, []); + + var ex = await Should.ThrowAsync(() => client.ReadAsync(Path, 60, Ct)); + ex.Status.ShouldBe(AbStatusCodes.FromCip(0x05, [])); + } + + [Fact] + public async Task A_partial_reply_with_no_data_does_not_loop_forever() + { + var (ch, client, s) = Create(); + await using var _ = s; + ch.RespondPerService = r => FakeCipChannel.Reply(r[0], 0x06, TypedData(LogixTypeCode.DInt, [])); + + var ex = await Should.ThrowAsync(() => client.ReadAsync(Path, 60, Ct)); + ex.Status.ShouldBe(AbStatus.BadReply); + ch.Sent.Count.ShouldBe(1); + } + + [Fact] + public async Task A_write_that_fits_is_one_write_tag() + { + var (ch, client, s) = Create(); + await using var _ = s; + var data = Bytes(16, 1); + + await client.WriteAsync(Path, LogixTypeCode.DInt, null, 4, data, Ct); + + ch.Sent.Count.ShouldBe(1); + ch.Sent[0].ShouldBe(LogixServices.WriteTag(Path, LogixTypeCode.DInt, null, 4, data)); + } + + [Fact] + public async Task A_write_exactly_at_the_limit_is_still_one_write_tag() + { + var data = Bytes(16, 1); + var size = LogixServices.WriteTag(Path, LogixTypeCode.DInt, null, 4, data).Length; + var (ch, client, s) = Create(max: size); + await using var _ = s; + + await client.WriteAsync(Path, LogixTypeCode.DInt, null, 4, data, Ct); + + ch.Sent.Count.ShouldBe(1); + ch.Sent[0][0].ShouldBe((byte)0x4D); + } + + [Theory] + [InlineData(LogixTypeCode.DInt, 52)] // room 54 -> 52 (13 DINTs) + [InlineData(LogixTypeCode.LInt, 48)] // room 54 -> 48 (6 LINTs) + [InlineData(LogixTypeCode.Int, 54)] // room 54 -> 54 (27 INTs) + public async Task Fragmented_write_chunks_align_to_element_width(ushort type, int expectedChunk) + { + const int room = 54; + var (ch, client, s) = Create(max: room + 12 + Path.Length); + await using var _ = s; + var width = LogixTypeCode.ByteWidth(type); + var data = Bytes(width * 40, 7); // 40 elements, more than fits in one frame + var elements = (ushort)40; + + await client.WriteAsync(Path, type, null, elements, data, Ct); + + ch.Sent.ShouldAllBe(r => r[0] == 0x53); + ch.Sent.ShouldAllBe(r => r.Length <= ch.MaxRequestBytes); + var reassembled = new List(); + foreach (var r in ch.Sent) + { + var body = r.AsSpan(2 + Path.Length); + BinaryPrimitives.ReadUInt16LittleEndian(body).ShouldBe(type); + BinaryPrimitives.ReadUInt16LittleEndian(body[2..]).ShouldBe(elements); // total element count + var offset = BinaryPrimitives.ReadUInt32LittleEndian(body[4..]); + offset.ShouldBe((uint)reassembled.Count); + var chunk = body[8..]; + (offset % (uint)width).ShouldBe(0u); + reassembled.AddRange(chunk.ToArray()); + } + reassembled.ShouldBe(data); + ch.Sent.Take(ch.Sent.Count - 1).ShouldAllBe(r => r.Length - (2 + Path.Length + 8) == expectedChunk); + } + + [Fact] + public async Task A_fragmented_struct_write_carries_the_handle_in_every_chunk() + { + const int room = 40; + var (ch, client, s) = Create(max: room + 12 + Path.Length); + await using var _ = s; + var data = Bytes(100, 3); // 10 elements of a 10-byte struct + + await client.WriteAsync(Path, LogixTypeCode.Struct, 0x1234, 10, data, Ct); + + ch.Sent.Count.ShouldBe(3); // 40 + 40 + 20 + foreach (var r in ch.Sent) + { + var body = r.AsSpan(2 + Path.Length); + BinaryPrimitives.ReadUInt16LittleEndian(body).ShouldBe(LogixTypeCode.Struct); + BinaryPrimitives.ReadUInt16LittleEndian(body[2..]).ShouldBe((ushort)0x1234); + r.Length.ShouldBeLessThanOrEqualTo(ch.MaxRequestBytes); + } + } + + [Fact] + public async Task A_struct_element_wider_than_one_frame_is_written_in_8_byte_aligned_contiguous_chunks() + { + const int room = 45; // aligns down to 40 + var (ch, client, s) = Create(max: room + 12 + Path.Length); + await using var _ = s; + var data = Bytes(200, 9); // 2 elements of a 100-byte struct (> room) + + await client.WriteAsync(Path, LogixTypeCode.Struct, 0x0ABC, 2, data, Ct); + + var reassembled = new List(); + foreach (var r in ch.Sent) + { + r[0].ShouldBe((byte)0x53); + r.Length.ShouldBeLessThanOrEqualTo(ch.MaxRequestBytes); + var body = r.AsSpan(2 + Path.Length); + BinaryPrimitives.ReadUInt16LittleEndian(body).ShouldBe(LogixTypeCode.Struct); + BinaryPrimitives.ReadUInt16LittleEndian(body[2..]).ShouldBe((ushort)0x0ABC); // handle + BinaryPrimitives.ReadUInt16LittleEndian(body[4..]).ShouldBe((ushort)2); // total elements + BinaryPrimitives.ReadUInt32LittleEndian(body[6..]).ShouldBe((uint)reassembled.Count); // contiguous + var chunk = body[10..]; + (chunk.Length % 8).ShouldBe(0); + reassembled.AddRange(chunk.ToArray()); + } + ch.Sent.Count.ShouldBe(5); + reassembled.ShouldBe(data); + } + + [Fact] + public async Task A_read_that_never_stops_answering_partial_is_capped() + { + var (ch, client, s) = Create(); + await using var _ = s; + ch.RespondPerService = r => FakeCipChannel.Reply(r[0], 0x06, TypedData(LogixTypeCode.DInt, new byte[4000])); + + var ex = await Should.ThrowAsync(() => client.ReadAsync(Path, 1000, Ct)); + + ex.Status.ShouldBe(AbStatus.TooLarge); + ch.Sent.Count.ShouldBe(LogixTagClient.MaxReadBytes / 4000 + 1); // stops at the first chunk past the cap + } + + [Theory] + [InlineData(LogixTypeCode.DInt, 4, 15)] + [InlineData(LogixTypeCode.DInt, 4, 17)] + [InlineData(LogixTypeCode.LReal, 2, 8)] + public async Task An_atomic_write_whose_data_does_not_match_its_element_count_is_rejected_before_the_wire( + ushort type, int elements, int length) + { + var (ch, client, s) = Create(); + await using var _ = s; + + await Should.ThrowAsync( + () => client.WriteAsync(Path, type, null, (ushort)elements, Bytes(length, 0), Ct)); + ch.SentCount.ShouldBe(0); + } + + [Fact] + public async Task A_failed_write_throws_with_the_reply_status() + { + var (ch, client, s) = Create(); + await using var _ = s; + ch.RespondPerService = r => FakeCipChannel.Reply(r[0], 0x0F, []); + + var ex = await Should.ThrowAsync( + () => client.WriteAsync(Path, LogixTypeCode.DInt, null, 1, Bytes(4, 0), Ct)); + ex.Status.ShouldBe(AbStatusCodes.FromCip(0x0F, [])); + } + + [Fact] + public async Task A_failing_fragment_stops_the_write() + { + var (ch, client, s) = Create(max: 54 + 12 + Path.Length); + await using var _ = s; + ch.RespondPerService = r => FakeCipChannel.Reply(r[0], 0x08, []); + + await Should.ThrowAsync( + () => client.WriteAsync(Path, LogixTypeCode.DInt, null, 40, Bytes(160, 0), Ct)); + ch.Sent.Count.ShouldBe(1); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTypeCodeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTypeCodeTests.cs new file mode 100644 index 00000000..8a570da5 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTypeCodeTests.cs @@ -0,0 +1,32 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Logix; + +public sealed class LogixTypeCodeTests +{ + [Theory] + [InlineData(LogixTypeCode.Bool, 1)] + [InlineData(LogixTypeCode.SInt, 1)] + [InlineData(LogixTypeCode.Int, 2)] + [InlineData(LogixTypeCode.DInt, 4)] + [InlineData(LogixTypeCode.LInt, 8)] + [InlineData(LogixTypeCode.USInt, 1)] + [InlineData(LogixTypeCode.UInt, 2)] + [InlineData(LogixTypeCode.UDInt, 4)] + [InlineData(LogixTypeCode.ULInt, 8)] + [InlineData(LogixTypeCode.Real, 4)] + [InlineData(LogixTypeCode.LReal, 8)] + [InlineData(LogixTypeCode.BoolArray, 4)] + [InlineData(LogixTypeCode.Struct, -1)] + public void Byte_width(ushort code, int width) => LogixTypeCode.ByteWidth(code).ShouldBe(width); + + [Fact] + public void Code_values() + { + LogixTypeCode.Bool.ShouldBe((ushort)0xC1); + LogixTypeCode.LReal.ShouldBe((ushort)0xCB); + LogixTypeCode.Struct.ShouldBe((ushort)0x02A0); + LogixTypeCode.BoolArray.ShouldBe((ushort)0xD3); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/SymbolObjectServicesTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/SymbolObjectServicesTests.cs new file mode 100644 index 00000000..d17ba8de --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/SymbolObjectServicesTests.cs @@ -0,0 +1,74 @@ +using System.Buffers.Binary; +using System.Text; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Logix; + +public sealed class SymbolObjectServicesTests +{ + private static readonly string[] ExpectedNames = + ["Motor_Run", "Line1_Count", "Pump_1_Speed", "TestArray", "TestString", "TestSINT", "TestBOOL", "TestREAL", "TestDINT"]; + + [Fact] + public void Request_matches_capture() + { + // libplctag requests attributes 2,7,8,1 in that order -- same as the brief; no change needed. + var cap = CaptureFile.CipPayload(CaptureFile.Load("logix-tags-listing.hex").First(f => f.FromClient)); + SymbolObjectServices.GetInstanceAttributeList(null, 0).ShouldBe(cap); + } + + [Fact] + public void Request_with_program_scope_and_start_instance() => + Convert.ToHexString(SymbolObjectServices.GetInstanceAttributeList("Main", 0x0102)).ShouldBe( + "550A" + "910C" + Convert.ToHexString(Encoding.ASCII.GetBytes("Program:Main")) + + "206B2500" + "0201" + "0400" + "0200" + "0700" + "0800" + "0100"); + + [Fact] + public void Captured_reply_entries_have_the_decoder_layout() + { + var reply = CipReply.Parse(CaptureFile.CipPayload(CaptureFile.Load("logix-tags-listing.hex").First(f => !f.FromClient))); + var (entries, last, more) = SymbolObjectServices.ParseListingReply(reply); + more.ShouldBeFalse(); + last.ShouldBe(9u); + + var names = new List(); + var pos = 0; + uint expectedInstance = 1; + while (pos < entries.Length) + { + BinaryPrimitives.ReadUInt32LittleEndian(entries.AsSpan(pos)).ShouldBe(expectedInstance++); + var nameLen = BinaryPrimitives.ReadUInt16LittleEndian(entries.AsSpan(pos + 20)); + names.Add(Encoding.ASCII.GetString(entries, pos + 22, nameLen)); + pos += 22 + nameLen; + } + names.ShouldBe(ExpectedNames); + } + + [Fact] + public void More_flag_follows_status_0x06() + { + var entry = Convert.FromHexString("05000000" + "C400" + "0400" + "00000000" + "00000000" + "00000000" + "0100" + "41"); + var reply = new CipReply(0x55, 0x06, [], entry); + var (entries, last, more) = SymbolObjectServices.ParseListingReply(reply); + more.ShouldBeTrue(); last.ShouldBe(5u); entries.ShouldBe(entry); + } + + [Fact] + public void Failure_status_throws() => + Should.Throw(() => + SymbolObjectServices.ParseListingReply(new CipReply(0x55, 0x04, [], ReadOnlyMemory.Empty))); + + [Fact] + public void Start_instance_above_16_bits_uses_32_bit_segment() => + Convert.ToHexString(SymbolObjectServices.GetInstanceAttributeList(null, 0x12345)).ShouldBe( + "5504" + "206B" + "2600" + "45230100" + "0400" + "0200" + "0700" + "0800" + "0100"); + + [Fact] + public void More_with_no_entries_throws() => + Should.Throw(() => + SymbolObjectServices.ParseListingReply(new CipReply(0x55, 0x06, [], ReadOnlyMemory.Empty))) + .Status.ShouldBe(AbStatus.BadReply); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/TemplateObjectServicesTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/TemplateObjectServicesTests.cs new file mode 100644 index 00000000..136af210 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/TemplateObjectServicesTests.cs @@ -0,0 +1,53 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Logix; + +// No template exchange exists in the golden captures (ab_server has no Template Object), so these +// are layout tests against the brief / CIP spec. +public sealed class TemplateObjectServicesTests +{ + [Fact] + public void Get_attributes_request() => + Convert.ToHexString(TemplateObjectServices.GetAttributes(0x0123)).ShouldBe( + "0303" + "206C2500" + "2301" + "0400" + "0400" + "0500" + "0200" + "0100"); + + [Fact] + public void Parse_attributes() + { + var reply = CipReply.Parse(Convert.FromHexString("83000000" + "0400" + + "0400" + "0000" + "2A000000" + + "0500" + "0000" + "18000000" + + "0200" + "0000" + "0300" + + "0100" + "0000" + "CE0F")); + var a = TemplateObjectServices.ParseAttributes(reply); + a.ShouldBe(new TemplateAttributes(42, 24, 3, 0x0FCE)); + } + + [Fact] + public void Parse_attributes_nonzero_attribute_status_throws() + { + var reply = CipReply.Parse(Convert.FromHexString("83000000" + "0100" + "0400" + "0100" + "2A000000")); + Should.Throw(() => TemplateObjectServices.ParseAttributes(reply)); + } + + [Fact] + public void Read_template_request() => + Convert.ToHexString(TemplateObjectServices.ReadTemplate(0x0123, 0x20, 0x1F4)).ShouldBe( + "4C03" + "206C2500" + "2301" + "20000000" + "F401"); + + [Fact] + public void Read_more_follows_status_0x06() + { + TemplateObjectServices.ReadHasMore(new CipReply(0x4C, 0x06, [], ReadOnlyMemory.Empty)).ShouldBeTrue(); + TemplateObjectServices.ReadHasMore(new CipReply(0x4C, 0x00, [], ReadOnlyMemory.Empty)).ShouldBeFalse(); + } + + [Fact] + public void Decoder_buffer_header() + { + var buf = TemplateObjectServices.BuildDecoderBuffer(0x0123, new TemplateAttributes(42, 24, 3, 0x0FCE), [0xAA, 0xBB]); + Convert.ToHexString(buf).ShouldBe("2301" + "2A000000" + "18000000" + "0300" + "CE0F" + "AABB"); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccAddressTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccAddressTests.cs new file mode 100644 index 00000000..a93a9be7 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccAddressTests.cs @@ -0,0 +1,55 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Pccc; + +public sealed class PcccAddressTests +{ + [Theory] + [InlineData("O", 0x8B)] [InlineData("I", 0x8C)] [InlineData("S", 0x84)] [InlineData("B", 0x85)] + [InlineData("T", 0x86)] [InlineData("C", 0x87)] [InlineData("R", 0x88)] [InlineData("N", 0x89)] + [InlineData("F", 0x8A)] [InlineData("ST", 0x8D)] [InlineData("A", 0x8E)] [InlineData("L", 0x91)] + public void File_type_codes(string letter, int code) => PcccFileType.CodeFor(letter).ShouldBe((byte)code); + + [Theory] + [InlineData("N7:0", "N", 7, 0, null, null)] + [InlineData("F8:12", "F", 8, 12, null, null)] + [InlineData("B3:1/4", "B", 3, 1, null, 4)] + [InlineData("ST9:2", "ST", 9, 2, null, null)] + [InlineData("T4:0.ACC", "T", 4, 0, 2, null)] + [InlineData("T4:1.PRE", "T", 4, 1, 1, null)] + [InlineData("T4:0.DN", "T", 4, 0, 0, 13)] + [InlineData("T4:0.EN", "T", 4, 0, 0, 15)] + [InlineData("C5:0.CU", "C", 5, 0, 0, 15)] + [InlineData("C5:0.OV", "C", 5, 0, 0, 12)] + [InlineData("C5:0.UA", "C", 5, 0, 0, 10)] + [InlineData("L9:0/31", "L", 9, 0, null, 31)] + [InlineData("R6:0.LEN", "R", 6, 0, 1, null)] + [InlineData("R6:0.POS", "R", 6, 0, 2, null)] + [InlineData("I:0/1", "I", null, 0, null, 1)] + [InlineData("n7:0", "N", 7, 0, null, null)] + public void Parses(string text, string letter, int? file, int element, int? sub, int? bit) + { + var a = PcccAddress.TryParse(text)!; + a.ShouldNotBeNull(); + a.FileLetter.ShouldBe(letter); + a.FileNumber.ShouldBe(file); + a.Element.ShouldBe(element); + a.ResolveSubElement().ShouldBe((sub ?? 0, bit)); + } + + [Theory] + [InlineData("")] [InlineData("N7")] [InlineData("N:0")] [InlineData("X7:0")] [InlineData("I1:0")] + [InlineData("T4:0.FOO")] [InlineData("N7:0.ACC")] [InlineData("N7:-1")] [InlineData("N7:0/x")] + [InlineData("N7:0/16")] [InlineData("F8:0/1")] [InlineData("L9:0/32")] [InlineData("T4:0/3")] + public void Rejects(string text) => PcccAddress.TryParse(text).ShouldBeNull(); + + [Fact] + public void Effective_file_number_is_implicit_for_IOS() + { + PcccAddress.TryParse("O:0")!.EffectiveFileNumber.ShouldBe(0); + PcccAddress.TryParse("I:0")!.EffectiveFileNumber.ShouldBe(1); + PcccAddress.TryParse("S:0")!.EffectiveFileNumber.ShouldBe(2); + PcccAddress.TryParse("N9:0")!.EffectiveFileNumber.ShouldBe(9); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs new file mode 100644 index 00000000..b8d36f68 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs @@ -0,0 +1,203 @@ +using System.Buffers.Binary; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Pccc; + +// ---- Step 1: hand-decode of the golden captures (the capture is the spec) ---- +// +// slc500-read-n7.hex, request frame (SendRRData 0x6F, one CPF item 0xB2 = unconnected data): +// 4B 02 20 67 24 01 Execute PCCC, path size 2 words: class 0x67 inst 1 -- SENT BARE: no Unconnected +// Send (0x52) wrapper, no route bytes, not connected (no Forward Open) +// 07 3D F3 45 43 50 21 requestor id: length 7, vendor 0xF33D (LE 3D F3), serial 0x21504345 +// 0F 00 99 87 A2 02 07 89 00 00 PCCC: cmd 0x0F, sts 0, TNS 0x8799, fnc 0xA2 (typed logical read), +// byteCount 2, file 7, type 0x89 (N), element 0, sub-element 0 +// reply item: CB 00 00 00 (service 0x4B|0x80, status 0, no ext) | 07 3D F3 45 43 50 21 | +// 4F 00 99 87 (cmd 0x4F, sts 0, same TNS) | E1 10 -> N7:0 = 0x10E1 = 4321 +// slc500-read-f8: ... A2 04 08 8A 00 00 (4 bytes, file 8, type 0x8A F) ; reply data 00 00 60 40 = 3.5f +// micrologix-read-n7: identical request shape; reply data A4 09 = 2468 +// slc500-write-n7: 0F 00 tns AA 02 07 89 00 00 E1 10 (fnc 0xAA, data appended); reply has no data +// plc5-read-n7: PLC-5 does NOT use 0xA2/0x68. Captured PCCC = 0F 00 01 | 00 00 | 01 00 | 06 07 00 | 02 : +// fnc 0x01 (range read), packet offset u16 0, total-trans u16 1, level mask 0x06 (levels file+element), +// file 7, element 0, then a single byte count 2. No file-type byte. Reply data 4D 05 = 1357. +// (The brief's fnc 0x68 / size-u16 shape is NOT what libplctag sends; the capture wins.) +public sealed class PcccServicesTests +{ + private const ushort Vendor = 0xF33D; + private const uint Serial = 0x21504345; + + private static byte[] Hex(string h) => Convert.FromHexString(h.Replace(" ", "")); + private static PcccAddress A(string s) => PcccAddress.TryParse(s)!; + + [Fact] + public void ExecutePccc_wraps_with_requestor_id() => + Convert.ToHexString(PcccServices.ExecutePccc(Vendor, Serial, [0xAA, 0xBB])) + .ShouldBe("4B0220672401" + "073DF3454350" + "21" + "AABB"); + + [Fact] + public void SlcTypedRead_N7_0() => + PcccServices.SlcTypedRead(0x8799, A("N7:0"), 2).ShouldBe(Hex("0F00 9987 A2 02 07 89 00 00")); + + [Fact] + public void SlcTypedRead_F8_3() => + PcccServices.SlcTypedRead(0x0102, A("F8:3"), 4).ShouldBe(Hex("0F00 0201 A2 04 08 8A 03 00")); + + [Fact] + public void SlcTypedWrite_N7_0() => + PcccServices.SlcTypedWrite(0x8799, A("N7:0"), [0x2A, 0x00]).ShouldBe(Hex("0F00 9987 AA 02 07 89 00 00 2A00")); + + [Fact] + public void Element_300_uses_FF_escape() => + PcccServices.SlcTypedRead(1, A("N7:300"), 2).ShouldBe(Hex("0F00 0100 A2 02 07 89 FF2C01 00")); + + [Fact] + public void File_300_uses_FF_escape() => + PcccServices.SlcTypedRead(1, A("N300:0"), 2).ShouldBe(Hex("0F00 0100 A2 02 FF2C01 89 00 00")); + + [Fact] + public void Value_255_uses_FF_escape_boundary() => + PcccServices.SlcTypedRead(1, A("N7:255"), 2).ShouldBe(Hex("0F00 0100 A2 02 07 89 FFFF00 00")); + + [Fact] + public void Timer_ACC_sub_element_is_word_2() => + PcccServices.SlcTypedRead(1, A("T4:0.ACC"), 2).ShouldBe(Hex("0F00 0100 A2 02 04 86 00 02")); + + [Fact] + public void Implicit_file_numbers_for_IOS() + { + PcccServices.SlcTypedRead(1, A("O:0"), 2).ShouldBe(Hex("0F00 0100 A2 02 00 8B 00 00")); + PcccServices.SlcTypedRead(1, A("I:1"), 2).ShouldBe(Hex("0F00 0100 A2 02 01 8C 01 00")); + PcccServices.SlcTypedRead(1, A("S:3"), 2).ShouldBe(Hex("0F00 0100 A2 02 02 84 03 00")); + } + + [Fact] + public void ByteCount_236_ok_237_throws() + { + PcccServices.SlcTypedRead(1, A("N7:0"), 236)[5].ShouldBe((byte)236); + Should.Throw(() => PcccServices.SlcTypedRead(1, A("N7:0"), 237)).Status.ShouldBe(AbStatus.TooLarge); + PcccServices.SlcTypedWrite(1, A("N7:0"), new byte[236]).Length.ShouldBe(10 + 236); // 0F 00 tns2 fnc count file type elem sub + data + Should.Throw(() => PcccServices.SlcTypedWrite(1, A("N7:0"), new byte[237])).Status.ShouldBe(AbStatus.TooLarge); + PcccServices.Plc5RangeRead(1, A("N7:0"), 1, 236)[^1].ShouldBe((byte)236); + Should.Throw(() => PcccServices.Plc5RangeRead(1, A("N7:0"), 1, 237)).Status.ShouldBe(AbStatus.TooLarge); + } + + [Fact] + public void Plc5RangeRead_N7_0_matches_captured_shape() => + PcccServices.Plc5RangeRead(0xC473, A("N7:0"), 1, 2).ShouldBe(Hex("0F00 73C4 01 0000 0100 06 07 00 02")); + + [Fact] + public void Plc5_write_is_not_shipped_and_flagged() => PcccServices.Plc5WriteVerified.ShouldBeFalse(); + + // ---- ParseReply ---- + private static CipReply Reply(string hex, byte general = 0) => + new(0x4B, general, [], Hex(hex)); + + private const string Id = "073DF3454350" + "21"; + + [Fact] + public void ParseReply_success_returns_data() => + PcccServices.ParseReply(Reply(Id + "4F00 9987 E110"), 0x8799).ToArray().ShouldBe(Hex("E110")); + + [Fact] + public void ParseReply_sts_0x10_is_Unsupported() => + Should.Throw(() => PcccServices.ParseReply(Reply(Id + "4F10 9987"), 0x8799)) + .Status.ShouldBe(AbStatus.Unsupported); + + [Fact] + public void ParseReply_F0_ext_06_is_NotFound() => + Should.Throw(() => PcccServices.ParseReply(Reply(Id + "4FF0 9987 06"), 0x8799)) + .Status.ShouldBe(AbStatus.NotFound); + + [Fact] + public void ParseReply_tns_mismatch_is_BadReply() => + Should.Throw(() => PcccServices.ParseReply(Reply(Id + "4F00 9987 E110"), 0x1234)) + .Status.ShouldBe(AbStatus.BadReply); + + [Fact] + public void ParseReply_truncated_is_BadReply() => + Should.Throw(() => PcccServices.ParseReply(Reply(Id + "4F00 99"), 0x8799)) + .Status.ShouldBe(AbStatus.BadReply); + + [Fact] + public void ParseReply_cip_error_status_throws() => + Should.Throw(() => PcccServices.ParseReply(Reply("", 0x08), 1)).Status.ShouldBe(AbStatus.Unsupported); + + [Fact] + public void ParseReply_wrong_command_byte_is_BadReply() => + Should.Throw(() => PcccServices.ParseReply(Reply(Id + "0F00 9987 E110"), 0x8799)) + .Status.ShouldBe(AbStatus.BadReply); + + [Fact] + public void ParseReply_stale_error_reply_with_wrong_tns_is_BadReply() => + Should.Throw(() => PcccServices.ParseReply(Reply(Id + "4F10 1111"), 0x8799)) + .Status.ShouldBe(AbStatus.BadReply); + + [Fact] + public void ParseReply_requestor_id_mismatch_is_BadReply() + { + var expected = Hex(Id); + PcccServices.ParseReply(Reply(Id + "4F00 9987 E110"), 0x8799, expected).Length.ShouldBe(2); + Should.Throw(() => + PcccServices.ParseReply(Reply("073DF3454350" + "22" + "4F00 9987 E110"), 0x8799, expected)) + .Status.ShouldBe(AbStatus.BadReply); + } + + // ---- Capture parity ---- + // The request is frame index 2 (after RegisterSession pair); its TNS is session-assigned, so we read it + // out of the capture and rebuild with that TNS (TNS is the only normalized field). Vendor/serial come + // from the captured requestor id. + private static (byte[] Request, byte[] Reply, ushort Tns) Exchange(string file, int requestIndex = 2) + { + var frames = CaptureFile.Load(file); + var req = CaptureFile.CipPayload(frames[requestIndex]); + var rep = CaptureFile.CipPayload(frames[requestIndex + 1]); + // 6 bytes of 4B-header + 7 requestor id + 0F 00 -> TNS at offset 15. + var tns = BinaryPrimitives.ReadUInt16LittleEndian(req.AsSpan(15, 2)); + return (req, rep, tns); + } + + private static byte[] Wrap(byte[] pccc) => PcccServices.ExecutePccc(Vendor, Serial, pccc); + + [Theory] + [InlineData("slc500-read-n7.hex", "N7:0", 2, 4321)] + [InlineData("micrologix-read-n7.hex", "N7:0", 2, 2468)] + public void Capture_read_n7_request_parity_and_value(string file, string addr, int bytes, int expected) + { + var (req, rep, tns) = Exchange(file); + Wrap(PcccServices.SlcTypedRead(tns, A(addr), bytes)).ShouldBe(req); + var data = PcccServices.ParseReply(CipReply.Parse(rep), tns).ToArray(); + data.Length.ShouldBe(2); + BinaryPrimitives.ReadInt16LittleEndian(data).ShouldBe((short)expected); + } + + [Fact] + public void Capture_slc500_read_f8_request_parity_and_value() + { + var (req, rep, tns) = Exchange("slc500-read-f8.hex"); + Wrap(PcccServices.SlcTypedRead(tns, A("F8:0"), 4)).ShouldBe(req); + var data = PcccServices.ParseReply(CipReply.Parse(rep), tns).ToArray(); + data.Length.ShouldBe(4); + BinaryPrimitives.ReadSingleLittleEndian(data).ShouldBe(3.5f); + } + + [Fact] + public void Capture_plc5_read_n7_request_parity_and_value() + { + var (req, rep, tns) = Exchange("plc5-read-n7.hex"); + Wrap(PcccServices.Plc5RangeRead(tns, A("N7:0"), 1, 2)).ShouldBe(req); + var data = PcccServices.ParseReply(CipReply.Parse(rep), tns).ToArray(); + data.Length.ShouldBe(2); + BinaryPrimitives.ReadInt16LittleEndian(data).ShouldBe((short)1357); + } + + [Fact] + public void Capture_slc500_write_n7_request_parity_and_empty_reply() + { + // frame 2 = initialize read, frame 4 = write of 4321 + var (req, rep, tns) = Exchange("slc500-write-n7.hex", 4); + Wrap(PcccServices.SlcTypedWrite(tns, A("N7:0"), [0xE1, 0x10])).ShouldBe(req); + PcccServices.ParseReply(CipReply.Parse(rep), tns).Length.ShouldBe(0); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Wire/CipReplyTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Wire/CipReplyTests.cs new file mode 100644 index 00000000..41026c2b --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Wire/CipReplyTests.cs @@ -0,0 +1,43 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Wire; + +public sealed class CipReplyTests +{ + [Fact] + public void Parses_success_reply_with_data() + { + // 0xCC = reply to Read Tag 0x4C; reserved; status 0; ext size 0; data C4 00 2A 00 00 00 + var r = CipReply.Parse(new byte[] { 0xCC, 0x00, 0x00, 0x00, 0xC4, 0x00, 0x2A, 0x00, 0x00, 0x00 }); + r.Service.ShouldBe((byte)0x4C); + r.GeneralStatus.ShouldBe((byte)0); + r.ExtendedStatus.Length.ShouldBe(0); + r.Data.ToArray().ShouldBe(new byte[] { 0xC4, 0x00, 0x2A, 0x00, 0x00, 0x00 }); + } + + [Fact] + public void Parses_reply_with_one_extended_word() + { + var r = CipReply.Parse(new byte[] { 0xCC, 0x00, 0xFF, 0x01, 0x07, 0x21 }); + r.ExtendedStatus.ToArray().ShouldBe(new ushort[] { 0x2107 }); + r.Status.ShouldBe(AbStatus.TypeMismatch); + } + + [Fact] + public void Parses_error_reply_with_two_extended_words() + { + var r = CipReply.Parse(new byte[] { 0xCC, 0x00, 0xFF, 0x02, 0x05, 0x21, 0x00, 0x00 }); + r.GeneralStatus.ShouldBe((byte)0xFF); + r.ExtendedStatus.ToArray().ShouldBe(new ushort[] { 0x2105, 0x0000 }); + r.Data.Length.ShouldBe(0); + r.Status.ShouldBe(AbStatus.OutOfBounds); + } + + [Fact] + public void Truncated_reply_throws_BadReply() + { + var ex = Should.Throw(() => CipReply.Parse(new byte[] { 0xCC, 0x00 })); + ex.Status.ShouldBe(AbStatus.BadReply); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj new file mode 100644 index 00000000..7cf4a40c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs index 51168dfb..a6a5c1cd 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs @@ -6,7 +6,7 @@ using ZB.MOM.WW.OtOpcUa.Driver.AbCip; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; /// -/// End-to-end smoke tests that exercise the real libplctag stack against a running +/// End-to-end smoke tests that exercise the real transport stack (libplctag and native, via ) against a running /// ab_server. Skipped when the binary isn't on PATH (). /// Parametrized over so one test file covers every family /// (ControlLogix / CompactLogix / Micro800 / GuardLogix). @@ -15,15 +15,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; [Trait("Requires", "AbServer")] public sealed class AbCipReadSmokeTests { - /// Gets the test profiles for the theory tests. - public static IEnumerable Profiles => - KnownProfiles.All.Select(p => new object[] { p }); - /// Verifies that the driver can read a seeded DInt value from an AB server. /// The AB server profile to test against. + /// The transport stack the driver runs on. [AbServerTheory] - [MemberData(nameof(Profiles))] - public async Task Driver_reads_seeded_DInt_from_ab_server(AbServerProfile profile) + [MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))] + public async Task Driver_reads_seeded_DInt_from_ab_server(AbServerProfile profile, AbCipTransport transport) { var fixture = new AbServerFixture(profile); await fixture.InitializeAsync(); @@ -33,13 +30,20 @@ public sealed class AbCipReadSmokeTests // (10.100.0.35) and AB_SERVER_ENDPOINT overrides reach this test too. The // sibling Emulate tests already use the fixture's resolved endpoint; this // smoke test was missed. - var deviceUri = $"ab://{fixture.Host}:{fixture.Port}/1,0"; + // Micro800 has no backplane: its cip-path is empty (the compose service runs + // ab_server --plc=Micro800 with no --path, which refuses a routed request). Every + // other family reaches slot 0 through `1,0`. Both transports are expected to pass + // for Micro800: the libplctag adapters map the empty route to a null `path` attribute + // (LibplctagPath.Normalize) because libplctag rejects "" with ErrorBadData. + var cipPath = profile.Family == AbCipPlcFamily.Micro800 ? "" : "1,0"; + var deviceUri = $"ab://{fixture.Host}:{fixture.Port}/{cipPath}"; var drv = new AbCipDriver(new AbCipDriverOptions { Devices = [new AbCipDeviceOptions(deviceUri, profile.Family)], RawTags = AbCipRawTags.From(new AbCipTagDefinition("Counter", deviceUri, "TestDINT", AbCipDataType.DInt)), Timeout = TimeSpan.FromSeconds(5), - }, $"drv-smoke-{profile.Family}"); + Transport = transport, + }, $"drv-smoke-{profile.Family}-{transport}"); await drv.InitializeAsync("{}", CancellationToken.None); var snapshots = await drv.ReadAsync(["Counter"], CancellationToken.None); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfile.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfile.cs index 066bfd50..28d950d1 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfile.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbServerProfile.cs @@ -44,7 +44,7 @@ public static class KnownProfiles public static readonly AbServerProfile CompactLogix = new( Family: AbCipPlcFamily.CompactLogix, ComposeProfile: "compactlogix", - Notes: "ab_server doesn't enforce the narrower ConnectionSize; driver-side profile caps it per PR 10."); + Notes: "Large Forward Open (4002) like libplctag since T21; ab_server accepts any size, so the regular-open fallback is unit-tested only."); /// Gets the Micro800 profile. public static readonly AbServerProfile Micro800 = new( diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialRun.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialRun.cs new file mode 100644 index 00000000..5be2d046 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialRun.cs @@ -0,0 +1,688 @@ +using System.Collections; +using System.Diagnostics; +using System.Net; +using System.Net.NetworkInformation; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential; + +/// One tag the harness reads, with the type it decodes under (null ⇒ raw comparison only). +internal sealed record SelectedTag(string Name, AbCipDataType? Type, int ElementCount, bool IsArray, string Origin); + +/// What one transport observed for one tag. +/// The CIP type code the reply carried, where the transport exposes it (native only: libplctag.NET +/// 1.5.2 does not surface it). +internal sealed record ReadObservation( + int Status, string? Error, byte[]? Raw, object? Decoded, string? DecodeError = null, ushort? TypeCode = null) +{ + public bool Succeeded => Status == (int)AbStatus.Ok && Error is null; +} + +/// What one transport observed over its whole phase. +internal sealed class TransportObservations +{ + public List Browse { get; } = []; + public string? BrowseError { get; set; } + public Dictionary Shapes { get; } = []; + public Dictionary TemplateErrors { get; } = []; + + /// Template ids that were queued but not read because AB_DIFF_MAX_TEMPLATES was reached. + public int TemplatesTruncated { get; set; } + public Dictionary Reads { get; } = new(StringComparer.Ordinal); + + /// + /// libplctag only: a SECOND paced read of every selected tag, taken after the first pass and before native + /// starts (T21 press pass 1). A tag whose two reads differ is live — its value moves on its own — and is + /// compared with native structurally, not by value. Empty on the native side. + /// + public Dictionary ProbeReads { get; } = new(StringComparer.Ordinal); +} + +/// The outcome of one whole differential run. +internal sealed class DifferentialResult +{ + public required DifferentialTarget Target { get; init; } + public required TransportObservations Libplctag { get; init; } + public required TransportObservations Native { get; init; } + public required IReadOnlyList Selection { get; init; } + + /// Native Forward Opens counted between the counter reset and the anchor release. + public required int NativeForwardOpens { get; init; } + + /// Forward Close frames the native side put on the wire (sent) and got a reply to (acked), over the + /// same window as — the anchor's release included (T21 review r1 M9). + public required NativeCloses NativeForwardCloses { get; init; } + + /// TCP connections to the endpoint once the native session was released and drained (null when unobservable). + public required int? ConnectionsAfterNative { get; init; } + + /// TCP connections to the endpoint before libplctag started, and once its handles were disposed. + public required int? ConnectionsBaseline { get; init; } + public required int? ConnectionsBeforeNative { get; init; } + public required TimeSpan LibplctagDrainWait { get; init; } + + /// The endpoint's resolved addresses, and this machine's connections to it before the run (null when + /// unobservable). Reused by the latency comparison's barriers, which must not start alongside a session. + public required IReadOnlySet Addresses { get; init; } + public required IReadOnlySet? BaselineEndpoints { get; init; } +} + +/// Forward Close frames the native side sent, and how many of them got a reply within their 1 s budget. +internal readonly record struct NativeCloses(int Sent, int Acked); + +/// +/// The run stopped at its first connection-level failure, or when the native side opened more than once. +/// Thrown, never swallowed: every live test then fails with this message. +/// +internal sealed class DifferentialAbortedException(string message, Exception? inner = null) + : Exception(message, inner); + +/// +/// Runs the whole comparison ONCE: every libplctag operation first (handles disposed), then — only +/// once libplctag's session is gone — every native operation under one anchor lease. Reads only: +/// browse listings, template reads and tag reads. Nothing on this path encodes or sends a write. +/// Stop on first failure (review r1 C1). The first connection-level failure on either side — +/// connect / RegisterSession / Forward Open refused, timeout, session lost — aborts the whole run with the +/// operation and status; nothing is retried. The native side is additionally capped at ONE open attempt +/// process-wide (), so a reconnect cannot reach the wire even +/// from inside an enumerator that swallows a per-program failure, and the run aborts the moment +/// passes 1. +/// +internal static class DifferentialRun +{ + private const int MaxArrays = 20; + private const int MaxUdtMembers = 20; + + /// Upper bound on waiting for libplctag's session to close before native work may start. + private static readonly TimeSpan LibplctagDrainBudget = TimeSpan.FromSeconds(30); + + /// Settle used when the platform cannot enumerate TCP connections. + private static readonly TimeSpan FallbackSettle = TimeSpan.FromSeconds(5); + + /// Bound on resolving the endpoint's host name (M5). + private static readonly TimeSpan ResolveBudget = TimeSpan.FromSeconds(5); + + /// + /// Statuses that describe ONE tag and leave the session healthy. Everything else — Timeout, BadConnection, + /// Abort, BadReply, Pending, any unexpected exception — is connection-level and aborts the run. + /// + private static readonly HashSet TagLevelStatuses = + [ + AbStatus.NotFound, AbStatus.NotAllowed, AbStatus.OutOfBounds, AbStatus.TooLarge, AbStatus.TooSmall, + AbStatus.Unsupported, AbStatus.TypeMismatch, + ]; + + public static async Task ExecuteAsync(DifferentialTarget target, CancellationToken ct) + { + var addresses = await ResolveAsync(target, ct).ConfigureAwait(false); + var baseline = LocalEndpointsTo(target, addresses); + + // ---- Phase 1: libplctag only. Every handle is disposed before the phase ends. ---- + var (lib, selection) = await RunLibplctagPhaseAsync(target, ct).ConfigureAwait(false); + + // ---- Barrier: libplctag's session must be gone before the native side connects. ---- + var drain = Stopwatch.StartNew(); + var beforeNative = await WaitForSessionsToCloseAsync(target, addresses, baseline, "libplctag", ct).ConfigureAwait(false); + drain.Stop(); + + // ---- Phase 2: native only, every operation sharing ONE connection held open by the anchor. ---- + var (native, opens, closes) = await RunNativePhaseAsync(target, selection, ct).ConfigureAwait(false); + + // ---- Barrier: the native session must be gone too (its Forward Close is awaited in the anchor's release). ---- + var afterNative = await WaitForSessionsToCloseAsync(target, addresses, baseline, "native", ct).ConfigureAwait(false); + + return new DifferentialResult + { + Target = target, + Libplctag = lib, + Native = native, + Selection = selection, + NativeForwardOpens = opens, + NativeForwardCloses = closes, + ConnectionsAfterNative = afterNative, + ConnectionsBaseline = baseline?.Count, + ConnectionsBeforeNative = beforeNative, + LibplctagDrainWait = drain.Elapsed, + Addresses = addresses, + BaselineEndpoints = baseline, + }; + } + + /// + /// libplctag shares one session (and one Forward Open) among the handles alive at the same time, and + /// closes it when the last one is destroyed. The per-tag create/read/dispose below would therefore open + /// a fresh session per tag; an anchor handle on the listing pseudo-tag, held for the whole phase, keeps + /// every libplctag operation on ONE session — the mirror of the native anchor lease. + /// + internal static Task<(TransportObservations Observations, IReadOnlyList Selection)> RunLibplctagPhaseAsync( + DifferentialTarget target, CancellationToken ct) + { + const string phase = "libplctag"; + var lib = new TransportObservations(); + var factories = AbCipTransportFactories.For(AbCipTransport.Libplctag); + return WithLibplctagAnchorAsync(phase, target, "@tags", async () => + { + await BrowseAsync(phase, target, factories.Enumerators, lib, afterEach: null, ct).ConfigureAwait(false); + await ReadTemplatesAsync(phase, target, factories.Templates, lib, afterEach: null, ct).ConfigureAwait(false); + var selection = Select(target, lib); + await ReadTagsAsync(phase, target, factories.Tags, selection, lib.Reads, afterEach: null, ct).ConfigureAwait(false); + // Volatility probe: read every tag again, paced, so a value that moves on its own (a live analog) is + // recognised as live rather than reported as a transport divergence when native reads it later. + await ReadTagsAsync(phase, target, factories.Tags, selection, lib.ProbeReads, afterEach: null, ct).ConfigureAwait(false); + return ((TransportObservations)lib, selection); + }, ct); + } + + /// + /// Holds one libplctag handle on for the whole of , so every + /// libplctag operation in it shares ONE session and ONE Forward Open. A failure to create the anchor aborts + /// before runs; the anchor is disposed when ends, either way. + /// Shared by the differential (anchor @tags) and the latency comparison (anchor = its first tag). + /// + internal static async Task WithLibplctagAnchorAsync( + string phase, DifferentialTarget target, string anchorTag, Func> body, CancellationToken ct) + { + var factories = AbCipTransportFactories.For(AbCipTransport.Libplctag); + using var anchor = factories.Tags.Create(target.Params(anchorTag)); + try + { + await anchor.InitializeAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested) + { + throw Abort(phase, $"anchor session ({anchorTag})", ex); + } + return await body().ConfigureAwait(false); + } + + /// The native phase alone; internal so the offline abort test can drive it against a fake endpoint. + internal static async Task<(TransportObservations Observations, int ForwardOpens, NativeCloses ForwardCloses)> RunNativePhaseAsync( + DifferentialTarget target, IReadOnlyList selection, CancellationToken ct) + { + const string phase = "native"; + var native = new TransportObservations(); + var factories = AbCipTransportFactories.For(AbCipTransport.Native); + var (_, forwardOpens, forwardCloses) = await WithNativeAnchorAsync(phase, target, async checkOpens => + { + await BrowseAsync(phase, target, factories.Enumerators, native, checkOpens, ct).ConfigureAwait(false); + await ReadTemplatesAsync(phase, target, factories.Templates, native, checkOpens, ct).ConfigureAwait(false); + await ReadTagsAsync(phase, target, factories.Tags, selection, native.Reads, checkOpens, ct).ConfigureAwait(false); + return true; + }).ConfigureAwait(false); + return (native, forwardOpens, forwardCloses); + } + + /// + /// Runs with every native operation sharing ONE connection, held open by an anchor + /// lease: the process-wide Forward Open counter is reset, the open-attempt budget is set to exactly one + /// more attempt, and receives a check to call after each operation that aborts the + /// moment a second Forward Open reaches the wire. The anchor is released and the budget restored in + /// finally. Shared by the differential and the latency comparison. + /// + /// The body's result, the Forward Opens counted between the reset and the anchor release, and the + /// Forward Closes counted between the reset and the END of the anchor release (which awaits the close). + internal static async Task<(T Result, int ForwardOpens, NativeCloses ForwardCloses)> WithNativeAnchorAsync( + string phase, DifferentialTarget target, Func, Task> body) + { + var anchorParams = target.Params("@tags"); + + void CheckOpens(string operation) + { + var opens = Volatile.Read(ref CipConnection.ForwardOpenCount); + if (opens > 1) + throw new DifferentialAbortedException( + $"Differential ABORTED in the {phase} phase after {operation}: {opens} Forward Open frames on the wire, " + + "expected exactly one (a reconnect, or a Large→regular fallback). Nothing was retried."); + } + + Interlocked.Exchange(ref CipConnection.ForwardOpenCount, 0); + Interlocked.Exchange(ref CipConnection.ForwardClosesSent, 0); + Interlocked.Exchange(ref CipConnection.ForwardClosesAcked, 0); + Volatile.Write(ref CipConnection.OpenAttemptBudget, Volatile.Read(ref CipConnection.OpenAttempts) + 1); + T result; + int forwardOpens; + NativeCloses forwardCloses; + try + { + // Acquire does no I/O; the key and options carry no tag-specific field, so every lease shares this entry. + var anchor = DeviceSessionPool.Shared.Acquire(NativeAbCipDevice.Key(anchorParams), NativeAbCipDevice.Options(anchorParams)); + try + { + result = await body(CheckOpens).ConfigureAwait(false); + } + finally + { + forwardOpens = Volatile.Read(ref CipConnection.ForwardOpenCount); + await anchor.DisposeAsync().ConfigureAwait(false); + forwardCloses = new NativeCloses( + Volatile.Read(ref CipConnection.ForwardClosesSent), Volatile.Read(ref CipConnection.ForwardClosesAcked)); + } + } + finally + { + Volatile.Write(ref CipConnection.OpenAttemptBudget, -1); + } + return (result, forwardOpens, forwardCloses); + } + + private static async Task BrowseAsync( + string phase, DifferentialTarget target, IAbCipTagEnumeratorFactory factory, TransportObservations into, + Action? afterEach, CancellationToken ct) + { + await PaceAsync(target, ct).ConfigureAwait(false); + using var enumerator = factory.Create(); + try + { + await foreach (var tag in enumerator.EnumerateAsync(target.Params("@tags"), ct).ConfigureAwait(false)) + { + into.Browse.Add(tag); + afterEach?.Invoke($"browse entry '{tag.Name}'"); + } + } + catch (Exception ex) when (ex is not DifferentialAbortedException && (ex is not OperationCanceledException || !ct.IsCancellationRequested)) + { + if (IsConnectionLevel(ex)) throw Abort(phase, "browse (@tags)", ex); + into.BrowseError = Describe(ex); + } + afterEach?.Invoke("browse"); + } + + /// Reads every template id the browse surfaced, then every nested id those shapes name. + private static async Task ReadTemplatesAsync( + string phase, DifferentialTarget target, IAbCipTemplateReaderFactory factory, TransportObservations into, + Action? afterEach, CancellationToken ct) + { + var queue = new Queue(into.Browse + .Where(t => t.TemplateInstanceId is not null) + .Select(t => t.TemplateInstanceId!.Value) + .Distinct() + .Order()); + var seen = new HashSet(queue); + using var reader = factory.Create(); + while (queue.Count > 0) + { + if (into.Shapes.Count + into.TemplateErrors.Count >= target.MaxTemplates) + { + into.TemplatesTruncated = queue.Count; + break; + } + var id = queue.Dequeue(); + await PaceAsync(target, ct).ConfigureAwait(false); + try + { + var shape = CipTemplateObjectDecoder.Decode(await reader.ReadAsync(target.Params($"@udt/{id}"), id, ct).ConfigureAwait(false)); + into.Shapes[id] = shape; + foreach (var nested in shape?.Members ?? []) + if (nested.NestedTemplateId is { } n && seen.Add(n)) queue.Enqueue(n); + } + catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested) + { + if (IsConnectionLevel(ex)) throw Abort(phase, $"template {id}", ex); + into.TemplateErrors[id] = Describe(ex); + } + afterEach?.Invoke($"template {id}"); + } + } + + /// + /// The tags to read, chosen from the libplctag browse (the run's first phase), so both transports + /// read exactly the same list. Without AB_DIFF_TAGS the three kinds are taken ROUND-ROBIN + /// (array, UDT member, scalar, ...) under their quotas — up to 20 arrays and 20 UDT members — so a + /// press with thousands of scalars still exercises arrays and UDT members at any cap (review r1 I2). + /// + internal static IReadOnlyList Select(DifferentialTarget target, TransportObservations browsed) + { + var byName = new Dictionary(StringComparer.Ordinal); + var scalars = new List(); + var arrays = new List(); + var members = new List(); + + foreach (var tag in browsed.Browse.Where(t => !t.IsSystemTag)) + { + var name = tag.ProgramScope is null ? tag.Name : $"Program:{tag.ProgramScope}.{tag.Name}"; + var type = tag.DataType; + AbCipUdtShape? shape = null; + if (type == AbCipDataType.Structure) + { + shape = tag.TemplateInstanceId is { } id ? browsed.Shapes.GetValueOrDefault(id) : null; + // A string-family struct (STRING, STR0016, ...) is read and presented as one String value. + if (shape is { IsStringFamily: true }) type = AbCipDataType.String; + } + + if (type != AbCipDataType.Structure) + { + var selected = new SelectedTag(name, type, tag.IsArray ? tag.ElementCount : 1, tag.IsArray, + tag.IsArray ? "array" : "scalar"); + byName.TryAdd(name, selected); + (tag.IsArray ? arrays : scalars).Add(selected); + continue; + } + + if (tag.IsArray || shape is null) continue; + foreach (var member in shape.Members) + { + var memberType = member.DataType; + if (memberType == AbCipDataType.Structure) + { + var nested = member.NestedTemplateId is { } n ? browsed.Shapes.GetValueOrDefault(n) : null; + if (nested is not { IsStringFamily: true }) continue; + memberType = AbCipDataType.String; + } + // Hidden host bytes of packed BOOL members ("ZZZZZZZZZZ..." in Logix) are not addressable. + if (member.Name.StartsWith("ZZZZZZZZZZ", StringComparison.Ordinal)) continue; + var isArray = member.ArrayLength > 1; + var selected = new SelectedTag($"{name}.{member.Name}", memberType, isArray ? member.ArrayLength : 1, + isArray, "udt member"); + byName.TryAdd(selected.Name, selected); + members.Add(selected); + } + } + + if (target.Tags is { } explicitNames) + return explicitNames + .Select(n => byName.GetValueOrDefault(n) ?? new SelectedTag(n, null, 1, false, "explicit (type unknown)")) + .DistinctBy(t => t.Name).Take(target.MaxTags).ToList(); + + var chosen = new List(); + var seen = new HashSet(StringComparer.Ordinal); + // Reference-type enumerators created ONCE: a boxed List.Enumerator struct would restart per round. + IEnumerator[] sources = + [ + arrays.Take(MaxArrays).GetEnumerator(), + members.Take(MaxUdtMembers).GetEnumerator(), + scalars.AsEnumerable().Take(int.MaxValue).GetEnumerator(), + ]; + try + { + var live = true; + while (live && chosen.Count < target.MaxTags) + { + live = false; + foreach (var source in sources) + { + if (chosen.Count >= target.MaxTags) break; + while (source.MoveNext()) + { + live = true; + if (seen.Add(source.Current.Name)) { chosen.Add(source.Current); break; } + } + } + } + } + finally + { + foreach (var source in sources) source.Dispose(); + } + return chosen; + } + + /// + /// Coverage problems of a default (browse-driven) selection: browse offered arrays or UDT members but + /// none were selected. Empty for an explicit AB_DIFF_TAGS list, which is the operator's choice. + /// + internal static List SelectionCoverageProblems( + DifferentialTarget target, TransportObservations browsed, IReadOnlyList selection) + { + var problems = new List(); + if (target.Tags is not null) return problems; + var available = Select(target with { MaxTags = DifferentialTarget.Ceiling }, browsed); + foreach (var origin in new[] { "array", "udt member" }) + if (available.Any(t => t.Origin == origin) && selection.All(t => t.Origin != origin)) + problems.Add($"browse offered {origin}s but the selection holds none"); + return problems; + } + + private static async Task ReadTagsAsync( + string phase, DifferentialTarget target, IAbCipTagFactory factory, IReadOnlyList selection, + Dictionary into, Action? afterEach, CancellationToken ct) + { + foreach (var tag in selection) + { + ct.ThrowIfCancellationRequested(); + var runtime = factory.Create(target.Params(tag.Name, tag.ElementCount, tag.IsArray, tag.Type)); + try + { + string? error = null; + try + { + await PaceAsync(target, ct).ConfigureAwait(false); + await runtime.InitializeAsync(ct).ConfigureAwait(false); + await PaceAsync(target, ct).ConfigureAwait(false); + await runtime.ReadAsync(ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested) + { + if (IsConnectionLevel(ex)) throw Abort(phase, $"tag '{tag.Name}'", ex); + error = Describe(ex); + } + + var status = runtime.GetStatus(); + byte[]? raw = null; + object? decoded = null; + string? decodeError = null; + if (error is null) + { + try + { + raw = RawBufferOf(runtime).ToArray(); + if (tag.Type is { } type) + decoded = tag.IsArray ? runtime.DecodeArray(type, tag.ElementCount) : runtime.DecodeValue(type, null); + } + catch (Exception ex) + { + decodeError = Describe(ex); // recorded, and a NO verdict (review r1 M3) + } + } + into[tag.Name] = new ReadObservation(status, error, raw, decoded, decodeError, + (runtime as NativeAbCipTagRuntime)?.TypeCode); + } + finally + { + runtime.Dispose(); + } + afterEach?.Invoke($"tag '{tag.Name}'"); + } + } + + private static ReadOnlyMemory RawBufferOf(IAbCipTagRuntime runtime) => runtime switch + { + LibplctagTagRuntime l => l.RawBuffer, + NativeAbCipTagRuntime n => n.RawBuffer, + _ => throw new InvalidOperationException($"Unexpected runtime {runtime.GetType().Name}."), + }; + + /// True when the failure is about the session, not one tag: the run must stop. + internal static bool IsConnectionLevel(Exception ex) => ex switch + { + AbTransportException ate => !TagLevelStatuses.Contains(ate.Status), + // Local, pre-I/O refusals of one tag name (malformed path, unsupported type): no session involved. + ArgumentException or FormatException or NotSupportedException => false, + _ => true, + }; + + internal static DifferentialAbortedException Abort(string phase, string operation, Exception ex) => + new($"Differential ABORTED in the {phase} phase at {operation}: {Describe(ex)}. " + + "Stopped on the first connection-level failure; nothing was retried.", ex); + + internal static Task PaceAsync(DifferentialTarget target, CancellationToken ct) => + target.PaceMs > 0 ? Task.Delay(target.PaceMs, ct) : Task.CompletedTask; + + /// Resolves the endpoint once, bounded (an IP literal needs no lookup). + private static async Task> ResolveAsync(DifferentialTarget target, CancellationToken ct) + { + if (IPAddress.TryParse(target.Host, out var literal)) return new HashSet { literal.MapToIPv6() }; + using var bounded = CancellationTokenSource.CreateLinkedTokenSource(ct); + bounded.CancelAfter(ResolveBudget); + try + { + var resolved = await Dns.GetHostAddressesAsync(target.Host, bounded.Token).ConfigureAwait(false); + return resolved.Select(x => x.MapToIPv6()).ToHashSet(); + } + catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested) + { + throw new DifferentialAbortedException( + $"Differential ABORTED before any I/O: could not resolve '{target.Host}' within {ResolveBudget.TotalSeconds:0}s ({ex.Message}).", ex); + } + } + + /// + /// A transport closes its session asynchronously (libplctag on a background thread some time after the last + /// handle is destroyed). Wait until no TCP connection to the endpoint remains that was not already there + /// before the run (compared by local endpoint, not by count — review r1 M6), so two sessions are never + /// connected to the controller at the same time. + /// + /// Names the session waited for, in the abort message. + /// The connection count observed when the next phase may start (null when unobservable). + internal static async Task WaitForSessionsToCloseAsync( + DifferentialTarget target, IReadOnlySet addresses, IReadOnlySet? baseline, string whose, + CancellationToken ct) + { + if (baseline is null) + { + await Task.Delay(FallbackSettle, ct).ConfigureAwait(false); + return null; + } + var deadline = Stopwatch.StartNew(); + while (true) + { + var now = LocalEndpointsTo(target, addresses); + if (now is null) return null; + var added = now.Where(e => !baseline.Contains(e)).ToList(); + if (added.Count == 0) return now.Count; + if (deadline.Elapsed > LibplctagDrainBudget) + throw new DifferentialAbortedException( + $"{whose}'s session to {target.Host}:{target.Port} was still open {LibplctagDrainBudget.TotalSeconds:0}s after it was " + + $"released (local {string.Join(", ", added)}); refusing to start the next phase alongside it."); + await Task.Delay(TimeSpan.FromMilliseconds(250), ct).ConfigureAwait(false); + } + } + + /// Local endpoints of this machine's open TCP connections to the target, or null when the OS cannot say. + private static IReadOnlySet? LocalEndpointsTo(DifferentialTarget target, IReadOnlySet addresses) + { + try + { + return IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpConnections() + .Where(c => c.RemoteEndPoint.Port == target.Port + && addresses.Contains(c.RemoteEndPoint.Address.MapToIPv6()) + && c.State is not (TcpState.Closed or TcpState.TimeWait or TcpState.Unknown)) + .Select(c => c.LocalEndPoint) + .ToHashSet(); + } + catch (Exception ex) when (ex is PlatformNotSupportedException or NetworkInformationException or NotImplementedException) + { + return null; + } + } + + /// Order-insensitive multiset difference: entries of missing from . + internal static List Missing(IEnumerable a, IEnumerable b) where T : notnull + { + var counts = b.GroupBy(x => x).ToDictionary(g => g.Key, g => g.Count()); + var missing = new List(); + foreach (var x in a) + { + if (counts.TryGetValue(x, out var c) && c > 0) counts[x] = c - 1; + else missing.Add(x); + } + return missing; + } + + /// Structural equality for decoded values: arrays element-wise, NaN equal to NaN. + internal static bool ValuesEqual(object? a, object? b) => + a is Array || b is Array + ? StructuralComparisons.StructuralEqualityComparer.Equals(a, b) + : Equals(a, b); + + /// + /// True when the libplctag volatility probe saw the value move by itself: both libplctag reads succeeded and + /// their bytes differ. A missing or failed probe is never live, so such a tag keeps the full value comparison. + /// + internal static bool IsLive(ReadObservation lib, ReadObservation? probe) => + probe is not null && lib.Succeeded && probe.Succeeded && lib.Raw is not null && probe.Raw is not null + && !lib.Raw.AsSpan().SequenceEqual(probe.Raw); + + /// + /// The value check that survives for a LIVE tag (T21 review r1 I1). A scalar is one element, an array element-wise + /// over the decoded values: + /// + /// an element EQUAL in both libplctag reads did not move, so native must read it equal too — + /// otherwise **NO: stable element i** (a stride, offset or byte-order defect lands here); + /// an element that moved is excused its value, but native must not read a NaN/∞ that neither + /// libplctag read saw — **NO: non-finite element i**. + /// + /// Null means the check passed. Shapes must already agree between libplctag and native. + /// + internal static string? LiveElementMismatch(object? lib, object? probe, object? nat) + { + var l = Elements(lib); + var p = Elements(probe); + var n = Elements(nat); + if (l.Length != p.Length) return "**NO: probe shape**"; // libplctag disagreed with itself: never excuse it + if (l.Length != n.Length) return "**NO: shape**"; + for (var i = 0; i < l.Length; i++) + { + if (Equals(l[i], p[i])) + { + if (!Equals(l[i], n[i])) return $"**NO: stable element {i}**"; + } + else if (!IsFinite(n[i]) && !Equals(n[i], l[i]) && !Equals(n[i], p[i])) + { + return $"**NO: non-finite element {i}**"; + } + } + return null; + } + + private static object?[] Elements(object? v) => v is Array a ? a.Cast().ToArray() : [v]; + + private static bool IsFinite(object? v) => v switch + { + float f => float.IsFinite(f), + double d => double.IsFinite(d), + _ => true, + }; + + /// The same decoded shape: both scalars of one CLR type, or arrays of one element type and length. + internal static bool SameShape(object? a, object? b) => (a, b) switch + { + (null, null) => true, + (Array x, Array y) => x.GetType() == y.GetType() && x.Length == y.Length, + (Array, _) or (_, Array) => false, + (not null, not null) => a.GetType() == b.GetType(), + _ => false, + }; + + /// Equal only when BOTH decoded: an undecodable shape is never parity (review r1 I3). + internal static bool ShapesEqual(AbCipUdtShape? a, AbCipUdtShape? b) => + a is not null && b is not null + && a.TypeName == b.TypeName && a.TotalSize == b.TotalSize && a.Members.SequenceEqual(b.Members); + + /// + /// Template-parity problems; empty means parity. Vacuity is a problem: browse found structured tags + /// but no template id was compared (review r1 I3). + /// + internal static List TemplateProblems(TransportObservations lib, TransportObservations native) + { + var problems = new List(); + var ids = lib.Shapes.Keys.Concat(lib.TemplateErrors.Keys) + .Concat(native.Shapes.Keys).Concat(native.TemplateErrors.Keys) + .Distinct().Order().ToList(); + var structured = lib.Browse.Concat(native.Browse).Count(t => t.DataType == AbCipDataType.Structure); + if (structured > 0 && ids.Count == 0) + problems.Add($"vacuous: browse found {structured} structured tag(s) but no template id was compared"); + foreach (var id in ids) + { + var libShape = lib.Shapes.GetValueOrDefault(id); + var nativeShape = native.Shapes.GetValueOrDefault(id); + if (!ShapesEqual(libShape, nativeShape)) + problems.Add($"template {id}: " + (libShape is null || nativeShape is null ? "undecodable or unread on a side" : "shapes differ")); + } + return problems; + } + + internal static string Describe(Exception ex) => ex is AbTransportException ate + ? $"{ate.Status}: {ate.Message}" + : $"{ex.GetType().Name}: {ex.Message}"; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialSafetyTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialSafetyTests.cs new file mode 100644 index 00000000..5a6bbd3a --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialSafetyTests.cs @@ -0,0 +1,199 @@ +using System.Net; +using System.Net.Sockets; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential; + +/// +/// Offline pins for the differential's press-safety rules (Task 18 review r1). No controller is touched: +/// the "endpoint" is a loopback listener that accepts and immediately hangs up. +/// +[Collection(DifferentialCollection.Name)] +public sealed class DifferentialSafetyTests +{ + [Fact] + public async Task A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect() + { + await using var endpoint = HangUpListener.Start(); + var target = Target(endpoint.Port); + IReadOnlyList selection = + [ + new("A", AbCipDataType.DInt, 1, false, "scalar"), + new("B", AbCipDataType.DInt, 1, false, "scalar"), + new("C", AbCipDataType.DInt, 1, false, "scalar"), + ]; + + var ex = await Should.ThrowAsync( + () => DifferentialRun.RunNativePhaseAsync(target, selection, TestContext.Current.CancellationToken)); + + ex.Message.ShouldContain("native phase at browse"); + // Long enough for a throttled (≥ 1 s) reconnect to have reached the listener, had one been allowed. + await Task.Delay(TimeSpan.FromSeconds(2.5), TestContext.Current.CancellationToken); + endpoint.Connections.ShouldBe(1); + Volatile.Read(ref CipConnection.OpenAttemptBudget).ShouldBe(-1, "the budget is restored after the phase"); + } + + [Fact] + public async Task A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read() + { + await using var endpoint = HangUpListener.Start(); + var target = Target(endpoint.Port); + + var ex = await Should.ThrowAsync( + () => DifferentialRun.RunLibplctagPhaseAsync(target, TestContext.Current.CancellationToken)); + + ex.Message.ShouldContain("libplctag phase at"); + await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + // libplctag's own session layer may redial inside its one anchor-create timeout; the harness cannot + // reach below that. Recorded, and bounded: one anchor create, never one per tag. + TestContext.Current.TestOutputHelper?.WriteLine($"libplctag connects before the abort: {endpoint.Connections}"); + endpoint.Connections.ShouldBeInRange(1, 2); + } + + [Fact] + public void The_default_selection_keeps_arrays_and_udt_members_at_any_cap() + { + var browsed = new TransportObservations(); + for (var i = 0; i < 300; i++) browsed.Browse.Add(new AbCipDiscoveredTag($"S{i}", null, AbCipDataType.DInt, false)); + for (var i = 0; i < 30; i++) + browsed.Browse.Add(new AbCipDiscoveredTag($"A{i}", null, AbCipDataType.Real, false, ElementCount: 4, IsArray: true)); + browsed.Browse.Add(new AbCipDiscoveredTag("U", null, AbCipDataType.Structure, false, TemplateInstanceId: 7)); + browsed.Shapes[7] = new AbCipUdtShape("Udt", 400, + [.. Enumerable.Range(0, 25).Select(i => new AbCipUdtMember($"M{i}", i * 4, AbCipDataType.DInt, 1))]); + + var at200 = DifferentialRun.Select(Target(1) with { MaxTags = 200 }, browsed); + at200.Count.ShouldBe(200); + at200.Count(t => t.Origin == "array").ShouldBe(20); + at200.Count(t => t.Origin == "udt member").ShouldBe(20); + at200.Count(t => t.Origin == "scalar").ShouldBe(160); + + var at25 = DifferentialRun.Select(Target(1) with { MaxTags = 25 }, browsed); + at25.Count.ShouldBe(25); + at25.ShouldContain(t => t.Origin == "array"); + at25.ShouldContain(t => t.Origin == "udt member"); + at25.ShouldContain(t => t.Origin == "scalar"); + DifferentialRun.SelectionCoverageProblems(Target(1) with { MaxTags = 25 }, browsed, at25).ShouldBeEmpty(); + + // The coverage check is not vacuous: a selection of scalars only is flagged for both kinds. + DifferentialRun.SelectionCoverageProblems(Target(1), browsed, [.. at200.Where(t => t.Origin == "scalar")]) + .Count.ShouldBe(2); + } + + [Fact] + public void Template_parity_fails_when_vacuous_or_undecodable() + { + var lib = new TransportObservations(); + var native = new TransportObservations(); + lib.Browse.Add(new AbCipDiscoveredTag("U", null, AbCipDataType.Structure, false, TemplateInstanceId: 7)); + DifferentialRun.TemplateProblems(lib, native).ShouldHaveSingleItem().ShouldContain("vacuous"); + + lib.Shapes[7] = null; + native.Shapes[7] = null; + DifferentialRun.TemplateProblems(lib, native).ShouldHaveSingleItem().ShouldContain("undecodable"); + + var shape = new AbCipUdtShape("Udt", 4, [new AbCipUdtMember("M", 0, AbCipDataType.DInt, 1)]); + lib.Shapes[7] = shape; + native.Shapes[7] = shape with { Members = [new AbCipUdtMember("M", 0, AbCipDataType.DInt, 1)] }; + DifferentialRun.TemplateProblems(lib, native).ShouldBeEmpty(); + } + + [Fact] + public void Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping() + { + var saved = Snapshot(DifferentialTarget.EndpointVar, DifferentialTarget.MaxTagsVar, DifferentialTarget.MaxTemplatesVar, DifferentialTarget.PaceVar, + DifferentialTarget.LatencyTagsVar, DifferentialTarget.LatencyPassesVar, DifferentialTarget.LatencyPassGapVar); + try + { + Environment.SetEnvironmentVariable(DifferentialTarget.EndpointVar, "127.0.0.1:1"); + Environment.SetEnvironmentVariable(DifferentialTarget.MaxTagsVar, "501"); + Should.Throw(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.MaxTagsVar); + + Environment.SetEnvironmentVariable(DifferentialTarget.MaxTagsVar, "500"); + Environment.SetEnvironmentVariable(DifferentialTarget.MaxTemplatesVar, "9999"); + Should.Throw(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.MaxTemplatesVar); + + Environment.SetEnvironmentVariable(DifferentialTarget.MaxTemplatesVar, null); + Environment.SetEnvironmentVariable(DifferentialTarget.PaceVar, null); + Environment.SetEnvironmentVariable(DifferentialTarget.LatencyTagsVar, null); + Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassesVar, null); + Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassGapVar, null); + var t =DifferentialTarget.FromEnvironment()!; + t.MaxTags.ShouldBe(500); + t.MaxTemplates.ShouldBe(DifferentialTarget.DefaultMaxTemplates); + t.PaceMs.ShouldBe(DifferentialTarget.DefaultPaceMs); + t.LatencyTags.ShouldBe(100); + t.LatencyPasses.ShouldBe(30); + t.LatencyPassGapMs.ShouldBe(200); + + // Latency knobs (Task 21): the same fail-fast rule, with tighter ceilings. + Environment.SetEnvironmentVariable(DifferentialTarget.LatencyTagsVar, "201"); + Should.Throw(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.LatencyTagsVar); + Environment.SetEnvironmentVariable(DifferentialTarget.LatencyTagsVar, "200"); + Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassesVar, "51"); + Should.Throw(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.LatencyPassesVar); + Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassesVar, "50"); + Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassGapVar, "10001"); + Should.Throw(DifferentialTarget.FromEnvironment).Message.ShouldContain(DifferentialTarget.LatencyPassGapVar); + Environment.SetEnvironmentVariable(DifferentialTarget.LatencyPassGapVar, "0"); + var lat = DifferentialTarget.FromEnvironment()!; + (lat.LatencyTags, lat.LatencyPasses, lat.LatencyPassGapMs).ShouldBe((200, 50, 0)); + } + finally + { + foreach (var (k, v) in saved) Environment.SetEnvironmentVariable(k, v); + } + } + + private static DifferentialTarget Target(int port) => + new("127.0.0.1", port, "1,0", AbCipPlcFamily.CompactLogix, null, 200, CountOpens: false, MaxTemplates: 50, PaceMs: 0); + + private static (string, string?)[] Snapshot(params string[] names) => + [.. names.Select(n => (n, Environment.GetEnvironmentVariable(n)))]; + + /// Accepts every connection and closes it at once, counting them. + private sealed class HangUpListener : IAsyncDisposable + { + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _stop = new(); + private readonly Task _loop; + private int _connections; + + private HangUpListener() + { + _listener.Start(); + _loop = Task.Run(AcceptLoopAsync); + } + + public static HangUpListener Start() => new(); + + public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + + public int Connections => Volatile.Read(ref _connections); + + private async Task AcceptLoopAsync() + { + try + { + while (!_stop.IsCancellationRequested) + { + using var socket = await _listener.AcceptSocketAsync(_stop.Token); + Interlocked.Increment(ref _connections); + socket.Close(); + } + } + catch (OperationCanceledException) { } + catch (SocketException) { } + catch (ObjectDisposedException) { } + } + + public async ValueTask DisposeAsync() + { + await _stop.CancelAsync(); + _listener.Stop(); + try { await _loop; } catch { /* stopped */ } + _stop.Dispose(); + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialTarget.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialTarget.cs new file mode 100644 index 00000000..c19c630b --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialTarget.cs @@ -0,0 +1,146 @@ +using System.Globalization; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential; + +/// +/// The one endpoint the read-only differential harness targets, read from the environment. +/// +/// AB_DIFF_ENDPOINT — host:port (port defaults to 44818). Absent ⇒ every live test skips. +/// AB_DIFF_PATH — CIP path, default 1,0. +/// AB_DIFF_FAMILY — an name, default CompactLogix. +/// AB_DIFF_TAGS — optional comma list of tag names; absent ⇒ selected from browse. +/// AB_DIFF_MAX_TAGS — cap on the selected tags, default 200, at most 500 (higher fails fast). +/// AB_DIFF_MAX_TEMPLATES — cap on template reads per transport, default 50, at most 500. +/// AB_DIFF_PACE_MS — delay before every harness-issued request, default 20 ms; 0 for ab_server. +/// AB_DIFF_COUNT_OPENS — 1 ⇒ assert exactly one native Forward Open over the run. +/// AB_LAT_TAGS — latency comparison: tags read per pass, default 100, at most 200 (higher fails fast). +/// AB_LAT_PASSES — latency comparison: passes per transport, default 30, at most 50. +/// AB_LAT_PASS_GAP_MS — latency comparison: pause between passes, default 200 ms, at most 10000. +/// +/// +internal sealed record DifferentialTarget( + string Host, + int Port, + string CipPath, + AbCipPlcFamily Family, + IReadOnlyList? Tags, + int MaxTags, + bool CountOpens, + int MaxTemplates = DifferentialTarget.DefaultMaxTemplates, + int PaceMs = DifferentialTarget.DefaultPaceMs, + int LatencyTags = DifferentialTarget.DefaultLatencyTags, + int LatencyPasses = DifferentialTarget.DefaultLatencyPasses, + int LatencyPassGapMs = DifferentialTarget.DefaultLatencyPassGapMs) +{ + public const string EndpointVar = "AB_DIFF_ENDPOINT"; + public const string PathVar = "AB_DIFF_PATH"; + public const string FamilyVar = "AB_DIFF_FAMILY"; + public const string TagsVar = "AB_DIFF_TAGS"; + public const string MaxTagsVar = "AB_DIFF_MAX_TAGS"; + public const string CountOpensVar = "AB_DIFF_COUNT_OPENS"; + public const string MaxTemplatesVar = "AB_DIFF_MAX_TEMPLATES"; + public const string PaceVar = "AB_DIFF_PACE_MS"; + public const string LatencyTagsVar = "AB_LAT_TAGS"; + public const string LatencyPassesVar = "AB_LAT_PASSES"; + public const string LatencyPassGapVar = "AB_LAT_PASS_GAP_MS"; + + /// Latency comparison ceilings: a production press is the target, and each pass is one burst. + public const int LatencyTagsCeiling = 200; + public const int LatencyPassesCeiling = 50; + public const int DefaultLatencyTags = 100; + public const int DefaultLatencyPasses = 30; + public const int DefaultLatencyPassGapMs = 200; + + /// Hard ceiling on AB_DIFF_MAX_TAGS / AB_DIFF_MAX_TEMPLATES: a production press is the target. + public const int Ceiling = 500; + public const int DefaultMaxTemplates = 50; + public const int DefaultPaceMs = 20; + + private const int DefaultPort = 44818; + private const string DefaultPath = "1,0"; + private const AbCipPlcFamily DefaultFamily = AbCipPlcFamily.CompactLogix; + private const int DefaultMaxTags = 200; + + /// Per-operation timeout for both transports. + public static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(5); + + /// Skip reason used by every live test when the endpoint is not configured. + public static string SkipReason => + $"{EndpointVar} is not set — point it at host:port of the one controller to compare (e.g. 10.100.0.35:44818 for ab_server)."; + + /// The configured target, or null when AB_DIFF_ENDPOINT is absent. + /// An env var is present but malformed. + public static DifferentialTarget? FromEnvironment() + { + var endpoint = Environment.GetEnvironmentVariable(EndpointVar)?.Trim(); + if (string.IsNullOrEmpty(endpoint)) return null; + + var parts = endpoint.Split(':', 2); + var host = parts[0]; + var port = DefaultPort; + if (parts.Length == 2 && (!int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out port) || port is < 1 or > 65535)) + throw new InvalidOperationException($"{EndpointVar}='{endpoint}' is not host:port."); + if (host.Length == 0) throw new InvalidOperationException($"{EndpointVar}='{endpoint}' has no host."); + + // An explicitly empty AB_DIFF_PATH is honoured (direct / no-backplane targets). + var path = Environment.GetEnvironmentVariable(PathVar) ?? DefaultPath; + + var family = DefaultFamily; + if (Environment.GetEnvironmentVariable(FamilyVar) is { Length: > 0 } rawFamily + && !Enum.TryParse(rawFamily.Trim(), ignoreCase: true, out family)) + throw new InvalidOperationException( + $"{FamilyVar}='{rawFamily}' is not one of {string.Join(", ", Enum.GetNames())}."); + + IReadOnlyList? tags = null; + if (Environment.GetEnvironmentVariable(TagsVar) is { Length: > 0 } rawTags) + tags = rawTags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var max = BoundedInt(MaxTagsVar, DefaultMaxTags, 1, Ceiling); + var maxTemplates = BoundedInt(MaxTemplatesVar, DefaultMaxTemplates, 1, Ceiling); + var pace = BoundedInt(PaceVar, DefaultPaceMs, 0, 10_000); + if (tags is { Count: > Ceiling }) + throw new InvalidOperationException($"{TagsVar} names {tags.Count} tags; the ceiling is {Ceiling}."); + + var latencyTags = BoundedInt(LatencyTagsVar, DefaultLatencyTags, 1, LatencyTagsCeiling); + var latencyPasses = BoundedInt(LatencyPassesVar, DefaultLatencyPasses, 1, LatencyPassesCeiling); + var latencyGap = BoundedInt(LatencyPassGapVar, DefaultLatencyPassGapMs, 0, 10_000); + + var countOpens = Environment.GetEnvironmentVariable(CountOpensVar) == "1"; + return new DifferentialTarget(host, port, path, family, tags, max, countOpens, maxTemplates, pace, + latencyTags, latencyPasses, latencyGap); + } + + /// Reads an integer knob; out of range FAILS rather than clamping, so a typo never silently widens a press run. + private static int BoundedInt(string name, int fallback, int min, int max) + { + if (Environment.GetEnvironmentVariable(name) is not { Length: > 0 } raw) return fallback; + if (!int.TryParse(raw.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var value) || value < min || value > max) + throw new InvalidOperationException($"{name}='{raw}' must be an integer in [{min}, {max}]."); + return value; + } + + /// The family profile the driver would use for this target. + public AbCipPlcFamilyProfile Profile => AbCipPlcFamilyProfile.ForFamily(Family); + + /// Create params for one tag (or listing pseudo-tag), built the way the driver builds them. + public AbCipTagCreateParams Params( + string tagName, int elementCount = 1, bool isArray = false, AbCipDataType? authoredType = null) => new( + Gateway: Host, + Port: Port, + CipPath: CipPath, + LibplctagPlcAttribute: Profile.LibplctagPlcAttribute, + TagName: tagName, + Timeout: OperationTimeout, + AllowPacking: Profile.SupportsRequestPacking, + ConnectionSize: Profile.DefaultConnectionSize, + ElementCount: elementCount < 1 ? 1 : elementCount, + IsArray: isArray, + AuthoredType: authoredType, + UseConnectedMessaging: Profile.SupportsConnectedMessaging); + + /// + public override string ToString() => + $"{Host}:{Port} path '{CipPath}' family {Family} ({Profile.LibplctagPlcAttribute}, connection size {Profile.DefaultConnectionSize}), " + + $"max tags {MaxTags}, max templates {MaxTemplates}, pace {PaceMs} ms"; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs new file mode 100644 index 00000000..46255cbd --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs @@ -0,0 +1,134 @@ +using System.Globalization; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential; + +/// +/// Task 21 latency comparison: a fixed set of atomic tags polled through libplctag, then through the native +/// transport, each pass issuing every read at once. Pass criterion: native p95 ≤ libplctag p95 × 1.10. +/// Gated by the same variables as the differential (AB_DIFF_ENDPOINT absent ⇒ skip), in the same +/// serialized collection, and read-only under the same source/IL guard +/// (). It runs AFTER the +/// differential's one run, reusing its browse for tag selection, so the target sees one listing per transport, +/// not two. Knobs: AB_LAT_TAGS, AB_LAT_PASSES, AB_LAT_PASS_GAP_MS (see +/// ). +/// +[Collection(DifferentialCollection.Name)] +[Trait("Category", "LiveIntegration")] +public sealed class PollLatencyComparisonTests(DifferentialRunFixture fixture) +{ + private const double Tolerance = 1.10; + private static readonly TimeSpan RunBudget = TimeSpan.FromMinutes(10); + + [Fact] + public async Task Native_poll_p95_is_within_ten_percent_of_libplctag() + { + var target = DifferentialTarget.FromEnvironment(); + if (target is null) Assert.Skip(DifferentialTarget.SkipReason); + var differential = await fixture.GetAsync(); + + using var cts = new CancellationTokenSource(RunBudget); + var r = await PollLatencyRun.ExecuteAsync(target, differential, cts.Token); + + Log($"## Latency — {r.Target}"); + Log($"Tags per pass: {r.Tags.Count} ({r.DistinctTags} distinct" + + (r.Repeated ? $", REPEATED cyclically to reach {DifferentialTarget.LatencyTagsVar}={r.Target.LatencyTags}" : "") + + $"); passes per transport: {r.Target.LatencyPasses}; gap between passes: {r.Target.LatencyPassGapMs} ms."); + Log("Types: " + string.Join(", ", r.Tags.GroupBy(t => t.Type).Select(g => $"{g.Key} {g.Count()}"))); + if (r.Dropped.Count > 0) Log($"Dropped (libplctag refused at setup, removed from both sets): {string.Join(", ", r.Dropped)}"); + Log($"Native requests spent on setup (one read per tag): {r.NativeSetupRequests}; native Forward Opens: {r.NativeForwardOpens}; " + + $"native Forward Closes sent/acked: {r.NativeForwardCloses.Sent}/{r.NativeForwardCloses.Acked}; " + + $"connections after the native phase: {r.ConnectionsAfterNative?.ToString(CultureInfo.InvariantCulture) ?? "unobservable"}."); + Log($"libplctag orphans destroyed so far: {LibplctagOrphanRelease.Released}; rooted (unreleasable): {LibplctagOrphanRelease.RootedCount}."); + Log(""); + + var libP50 = PollLatencyRun.Percentile(r.Libplctag.Select(p => p.Wall), 50); + var libP95 = PollLatencyRun.Percentile(r.Libplctag.Select(p => p.Wall), 95); + var natP50 = PollLatencyRun.Percentile(r.Native.Select(p => p.Wall), 50); + var natP95 = PollLatencyRun.Percentile(r.Native.Select(p => p.Wall), 95); + var limit = libP95 * Tolerance; + + Log("| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |"); + Log("|---|---|---|---|---|---|---|---|"); + Log(Row("libplctag", r.Libplctag, libP50, libP95)); + Log(Row("native", r.Native, natP50, natP95)); + Log(""); + Log($"Criterion: native p95 {Ms(natP95)} ms ≤ libplctag p95 {Ms(libP95)} ms × {Tolerance:0.00} = {Ms(limit)} ms → " + + (natP95 <= limit ? "PASS" : "**FAIL**")); + Log(""); + // Attribution (review r1 M8): GC collections during each pass (gen0/gen1/gen2) on both sides, and the + // round trip of each native CIP exchange — a slow pass with fast exchanges is a client-side stall. + Log("| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |"); + Log("|---|---|---|---|---|---|---|"); + for (var i = 0; i < Math.Max(r.Libplctag.Count, r.Native.Count); i++) + { + var lib = i < r.Libplctag.Count ? r.Libplctag[i] : null; + var nat = i < r.Native.Count ? r.Native[i] : null; + Log($"| {i + 1} | {(lib is null ? "-" : Ms(lib.Wall.TotalMilliseconds))} | {lib?.Gc.ToString() ?? "-"} | " + + $"{(nat is null ? "-" : Ms(nat.Wall.TotalMilliseconds))} | {nat?.Gc.ToString() ?? "-"} | " + + $"{nat?.Requests?.ToString(CultureInfo.InvariantCulture) ?? "-"} | {Rtt(nat?.Rtt)} |"); + } + foreach (var p in r.Problems) Log($"- PROBLEM: {p}"); + + r.Problems.ShouldBeEmpty(); + r.Libplctag.Count.ShouldBe(r.Target.LatencyPasses); + r.Native.Count.ShouldBe(r.Target.LatencyPasses); + r.Libplctag.Sum(p => p.TagFailures).ShouldBe(0, "every timed libplctag read must succeed"); + r.Native.Sum(p => p.TagFailures).ShouldBe(0, "every timed native read must succeed"); + if (r.Target.CountOpens) + { + r.NativeForwardOpens.ShouldBe(1); + r.NativeForwardCloses.Sent.ShouldBe(1, "the native phase's one connection must be closed with exactly one Forward Close"); + } + if (r.ConnectionsAfterNative is { } after && differential.ConnectionsBaseline is { } baseline) + after.ShouldBeLessThanOrEqualTo(baseline, "the native session must be gone when the run reports"); + // Review r1 M4: the latency setup's refused libplctag tags are orphan candidates too — checked after them. + LibplctagOrphanRelease.RootedCount.ShouldBe(0); + natP95.ShouldBeLessThanOrEqualTo(limit, $"native p95 must be ≤ libplctag p95 × {Tolerance:0.00}"); + } + + /// Offline: the tag pick takes atomic scalars only, and repeats a short set deliberately and visibly. + [Fact] + public void Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly() + { + var browsed = new TransportObservations(); + browsed.Browse.Add(new AbCipDiscoveredTag("D", null, AbCipDataType.DInt, false)); + browsed.Browse.Add(new AbCipDiscoveredTag("R", null, AbCipDataType.Real, false)); + browsed.Browse.Add(new AbCipDiscoveredTag("S", null, AbCipDataType.String, false)); + browsed.Browse.Add(new AbCipDiscoveredTag("T", null, AbCipDataType.Dt, false)); + browsed.Browse.Add(new AbCipDiscoveredTag("A", null, AbCipDataType.DInt, false, ElementCount: 4, IsArray: true)); + browsed.Browse.Add(new AbCipDiscoveredTag("Sys", null, AbCipDataType.DInt, false, IsSystemTag: true)); + var target = new DifferentialTarget("127.0.0.1", 1, "1,0", AbCipPlcFamily.CompactLogix, null, 200, false, LatencyTags: 5); + + var (tags, distinct, repeated) = PollLatencyRun.PickTags(target, browsed); + distinct.ShouldBe(2); + repeated.ShouldBeTrue(); + tags.Select(t => t.Name).ShouldBe(["D", "R", "D", "R", "D"]); + + var (one, oneDistinct, oneRepeated) = PollLatencyRun.PickTags(target with { LatencyTags = 1 }, browsed); + one.ShouldHaveSingleItem().Name.ShouldBe("D"); + oneDistinct.ShouldBe(1); + oneRepeated.ShouldBeFalse(); + + PollLatencyRun.Percentile([TimeSpan.FromMilliseconds(3), TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(2)], 50) + .ShouldBe(2); + PollLatencyRun.Percentile(Enumerable.Range(1, 20).Select(i => TimeSpan.FromMilliseconds(i)), 95).ShouldBe(19); + } + + private static string Row(string name, IReadOnlyList passes, double p50, double p95) + { + var walls = passes.Select(p => p.Wall.TotalMilliseconds).ToList(); + var requests = passes.Where(p => p.Requests is not null).Select(p => p.Requests!.Value).Order().ToList(); + var medianRequests = requests.Count == 0 ? "n/a" : requests[requests.Count / 2].ToString(CultureInfo.InvariantCulture); + return $"| {name} | {passes.Count} | {Ms(p50)} | {Ms(p95)} | {Ms(walls.Min())} | {Ms(walls.Max())} | {medianRequests} | " + + $"{passes.Sum(p => p.TagFailures)} |"; + } + + private static string Rtt(ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip.CipConnection.ExchangeStats? s) => + s is not { Count: > 0 } x ? "-" : $"{Ms(x.MinMs)}/{Ms(x.MeanMs)}/{Ms(x.MaxMs)}"; + + private static string Ms(double ms) => ms.ToString("0.0", CultureInfo.InvariantCulture); + + private static void Log(string line) => TestContext.Current.TestOutputHelper?.WriteLine(line); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyRun.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyRun.cs new file mode 100644 index 00000000..c774cb60 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyRun.cs @@ -0,0 +1,268 @@ +using System.Diagnostics; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential; + +/// One timed burst: every tag read once, concurrently. +/// Native only: delta over the pass (keep-alives included). +/// Reads that ended with a tag-level status (a connection-level one aborts instead). +/// Collections that ran during the pass, per generation (T21 review r1 M8: a client-side stall shows here). +/// Native only: round trip of each CIP exchange in the pass, from (a slow +/// controller shows here; a slow pass with fast exchanges points at the client). +internal sealed record LatencyPass( + int Index, TimeSpan Wall, int? Requests, int TagFailures, GcDelta Gc = default, CipConnection.ExchangeStats? Rtt = null); + +/// GC collections per generation over a window (gen0 counts include gen1/gen2 collections, as the runtime reports). +internal readonly record struct GcDelta(int Gen0, int Gen1, int Gen2) +{ + public static GcDelta Now() => new(GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2)); + public static GcDelta operator -(GcDelta a, GcDelta b) => new(a.Gen0 - b.Gen0, a.Gen1 - b.Gen1, a.Gen2 - b.Gen2); + public override string ToString() => $"{Gen0}/{Gen1}/{Gen2}"; +} + +/// The outcome of the latency comparison. +internal sealed class LatencyResult +{ + public required DifferentialTarget Target { get; init; } + + /// The fixed set read every pass, in order (a name appears more than once only when ). + public required IReadOnlyList Tags { get; init; } + public required int DistinctTags { get; init; } + public required bool Repeated { get; init; } + + /// Names libplctag could not read at setup (tag-level status): dropped from BOTH transports' sets. + public required IReadOnlyList Dropped { get; init; } + public required IReadOnlyList Libplctag { get; init; } + public required IReadOnlyList Native { get; init; } + public required int NativeForwardOpens { get; init; } + + /// Forward Closes the native phase sent / got a reply to, its anchor release included (review r1 M9). + public required NativeCloses NativeForwardCloses { get; init; } + + /// TCP connections to the endpoint once the native phase's session was released and drained. + public required int? ConnectionsAfterNative { get; init; } + + /// Native requests spent creating the runtimes (one read each, before any timed pass). + public required int NativeSetupRequests { get; init; } + public required List Problems { get; init; } +} + +/// +/// Task 21's poll-latency comparison, read-only and built on the differential's machinery: the same +/// endpoint parsing, the same anchors (one libplctag handle, one native lease), the same open-attempt budget, +/// the same connection barrier and the same stop-on-first-connection-failure rule. +/// Order: the differential's run (its browse supplies the tags — no second listing), a barrier until its +/// native session is gone, every libplctag pass with every handle then disposed, a barrier until libplctag's +/// session is gone, then every native pass under one anchor lease. The two transports never overlap. +/// Per transport: create and read every tag once (untimed, paced by AB_DIFF_PACE_MS), then +/// AB_LAT_PASSES timed passes, each issuing all AB_LAT_TAGS reads at once (Task.WhenAll) +/// so the native shared scheduler can pack them into Multiple Service Packets as libplctag packs internally, +/// with AB_LAT_PASS_GAP_MS of quiet between passes. +/// +internal static class PollLatencyRun +{ + private static readonly HashSet Atomic = + [ + AbCipDataType.Bool, AbCipDataType.SInt, AbCipDataType.Int, AbCipDataType.DInt, AbCipDataType.LInt, + AbCipDataType.USInt, AbCipDataType.UInt, AbCipDataType.UDInt, AbCipDataType.ULInt, + AbCipDataType.Real, AbCipDataType.LReal, + ]; + + /// + /// The fixed tag set: atomic, non-array scalars, from the differential's libplctag browse — or, when + /// AB_DIFF_TAGS is set, those of its names whose browsed type is an atomic scalar. When fewer + /// distinct tags exist than AB_LAT_TAGS, the set is REPEATED cyclically up to the count (each + /// repeat is its own runtime/handle on the same name) and reported as such — never silently. + /// + internal static (IReadOnlyList Tags, int Distinct, bool Repeated) PickTags( + DifferentialTarget target, TransportObservations browsed) + { + var candidates = DifferentialRun.Select(target with { MaxTags = DifferentialTarget.Ceiling }, browsed) + .Where(t => !t.IsArray && t.Type is { } type && Atomic.Contains(type)) + .DistinctBy(t => t.Name) + .Take(target.LatencyTags) + .ToList(); + if (candidates.Count == 0 || candidates.Count >= target.LatencyTags) return (candidates, candidates.Count, false); + var repeated = Enumerable.Range(0, target.LatencyTags).Select(i => candidates[i % candidates.Count]).ToList(); + return (repeated, candidates.Count, true); + } + + public static async Task ExecuteAsync(DifferentialTarget target, DifferentialResult differential, CancellationToken ct) + { + var (picked, distinct, repeated) = PickTags(target, differential.Libplctag); + if (picked.Count == 0) + throw new InvalidOperationException("The browse offered no atomic scalar tags to time; nothing was read."); + + // The differential's native session must be gone before libplctag connects. + await DifferentialRun.WaitForSessionsToCloseAsync( + target, differential.Addresses, differential.BaselineEndpoints, "the differential's native", ct).ConfigureAwait(false); + + // ---- libplctag: setup, passes, every handle disposed (anchor last). ---- + var dropped = new List(); + var libFactory = AbCipTransportFactories.For(AbCipTransport.Libplctag).Tags; + // Anchor on a tag the differential already read Ok through libplctag, so the anchor is never the refusal. + var anchorTag = picked.FirstOrDefault(t => differential.Libplctag.Reads.TryGetValue(t.Name, out var o) && o.Succeeded) + ?? picked[0]; + var (libPasses, tags) = await DifferentialRun.WithLibplctagAnchorAsync("latency libplctag", target, anchorTag.Name, async () => + { + var runtimes = new List<(SelectedTag Tag, IAbCipTagRuntime Runtime)>(); + try + { + foreach (var tag in picked) + { + var rt = await CreateAsync("latency libplctag", target, libFactory, tag, ct).ConfigureAwait(false); + if (rt is null) { dropped.Add(tag.Name); continue; } + runtimes.Add((tag, rt)); + } + var passes = await RunPassesAsync("latency libplctag", target, runtimes, countRequests: false, afterEach: null, ct) + .ConfigureAwait(false); + return (passes, (IReadOnlyList)runtimes.Select(r => r.Tag).ToList()); + } + finally + { + foreach (var (_, rt) in runtimes) rt.Dispose(); + } + }, ct).ConfigureAwait(false); + + // ---- Barrier: libplctag's session must be gone before the native side connects. ---- + await DifferentialRun.WaitForSessionsToCloseAsync( + target, differential.Addresses, differential.BaselineEndpoints, "libplctag", ct).ConfigureAwait(false); + + // ---- native: the same (post-drop) set, under one anchor lease. ---- + var problems = new List(); + var nativeFactory = AbCipTransportFactories.For(AbCipTransport.Native).Tags; + var ((nativePasses, setupRequests), opens, closes) = await DifferentialRun.WithNativeAnchorAsync("latency native", target, async checkOpens => + { + var runtimes = new List<(SelectedTag Tag, IAbCipTagRuntime Runtime)>(); + try + { + var before = Volatile.Read(ref CipConnection.RequestsSent); + foreach (var tag in tags) + { + var rt = await CreateAsync("latency native", target, nativeFactory, tag, ct).ConfigureAwait(false); + checkOpens($"setup of '{tag.Name}'"); + if (rt is null) { problems.Add($"native could not read '{tag.Name}', which libplctag read"); continue; } + runtimes.Add((tag, rt)); + } + var setup = Volatile.Read(ref CipConnection.RequestsSent) - before; + var passes = await RunPassesAsync("latency native", target, runtimes, countRequests: true, checkOpens, ct) + .ConfigureAwait(false); + return (passes, setup); + } + finally + { + foreach (var (_, rt) in runtimes) rt.Dispose(); + } + }).ConfigureAwait(false); + + // ---- Barrier: the native session must be gone before the run reports (review r1 M9). ---- + var afterNative = await DifferentialRun.WaitForSessionsToCloseAsync( + target, differential.Addresses, differential.BaselineEndpoints, "latency native", ct).ConfigureAwait(false); + + return new LatencyResult + { + Target = target, + Tags = tags, + DistinctTags = distinct, + Repeated = repeated, + Dropped = dropped, + Libplctag = libPasses, + Native = nativePasses, + NativeForwardOpens = opens, + NativeForwardCloses = closes, + ConnectionsAfterNative = afterNative, + NativeSetupRequests = setupRequests, + Problems = problems, + }; + } + + /// Create + first read of one tag. Null on a tag-level refusal; a connection-level failure aborts. + private static async Task CreateAsync( + string phase, DifferentialTarget target, IAbCipTagFactory factory, SelectedTag tag, CancellationToken ct) + { + var rt = factory.Create(target.Params(tag.Name, tag.ElementCount, tag.IsArray, tag.Type)); + try + { + await DifferentialRun.PaceAsync(target, ct).ConfigureAwait(false); + await rt.InitializeAsync(ct).ConfigureAwait(false); + if (rt.GetStatus() == (int)AbStatus.Ok) return rt; + } + catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested) + { + if (DifferentialRun.IsConnectionLevel(ex)) + { + rt.Dispose(); + throw DifferentialRun.Abort(phase, $"setup of '{tag.Name}'", ex); + } + } + catch + { + rt.Dispose(); + throw; + } + rt.Dispose(); + return null; + } + + private static async Task> RunPassesAsync( + string phase, DifferentialTarget target, List<(SelectedTag Tag, IAbCipTagRuntime Runtime)> runtimes, + bool countRequests, Action? afterEach, CancellationToken ct) + { + var passes = new List(target.LatencyPasses); + for (var p = 1; p <= target.LatencyPasses; p++) + { + if (p > 1 && target.LatencyPassGapMs > 0) await Task.Delay(target.LatencyPassGapMs, ct).ConfigureAwait(false); + + var requestsBefore = Volatile.Read(ref CipConnection.RequestsSent); + CipConnection.ResetExchangeStats(); + var gcBefore = GcDelta.Now(); + var clock = Stopwatch.StartNew(); + var outcomes = await Task.WhenAll(runtimes.Select(r => ReadOnceAsync(r.Runtime, ct))).ConfigureAwait(false); + clock.Stop(); + var gc = GcDelta.Now() - gcBefore; + var rtt = countRequests ? CipConnection.ReadExchangeStats() : (CipConnection.ExchangeStats?)null; + var requests = Volatile.Read(ref CipConnection.RequestsSent) - requestsBefore; + + var failures = 0; + for (var i = 0; i < outcomes.Length; i++) + { + if (outcomes[i] is { } ex) + { + if (DifferentialRun.IsConnectionLevel(ex)) + throw DifferentialRun.Abort(phase, $"pass {p}, tag '{runtimes[i].Tag.Name}'", ex); + failures++; + } + else if (runtimes[i].Runtime.GetStatus() != (int)AbStatus.Ok) + { + failures++; + } + } + afterEach?.Invoke($"pass {p}"); + passes.Add(new LatencyPass(p, clock.Elapsed, countRequests ? requests : null, failures, gc, rtt)); + } + return passes; + } + + private static async Task ReadOnceAsync(IAbCipTagRuntime runtime, CancellationToken ct) + { + try + { + await runtime.ReadAsync(ct).ConfigureAwait(false); + return null; + } + catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested) + { + return ex; + } + } + + /// Nearest-rank percentile (p in (0,100]) of the pass wall times, in milliseconds. + internal static double Percentile(IEnumerable walls, double p) + { + var sorted = walls.Select(w => w.TotalMilliseconds).Order().ToList(); + if (sorted.Count == 0) throw new InvalidOperationException("No passes to take a percentile of."); + var rank = (int)Math.Ceiling(p / 100.0 * sorted.Count); + return sorted[Math.Clamp(rank, 1, sorted.Count) - 1]; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs new file mode 100644 index 00000000..453cb1f8 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs @@ -0,0 +1,447 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Reflection; +using System.Runtime.CompilerServices; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential; + +/// Runs the differential alone: no other suite may hold a session to the target meanwhile. +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class DifferentialCollection : ICollectionFixture +{ + public const string Name = "AbCip transport differential"; +} + +/// Performs the one run all differential tests read from, on first use. +public sealed class DifferentialRunFixture +{ + private static readonly TimeSpan RunBudget = TimeSpan.FromMinutes(15); + private readonly Lazy> _run = new(RunAsync); + + internal Task GetAsync() => _run.Value; + + private static async Task RunAsync() + { + var target = DifferentialTarget.FromEnvironment() + ?? throw new InvalidOperationException(DifferentialTarget.SkipReason); + using var cts = new CancellationTokenSource(RunBudget); + return await DifferentialRun.ExecuteAsync(target, cts.Token).ConfigureAwait(false); + } +} + +/// +/// Read-only differential (Task 18): the same tags through libplctag and the native transport against one +/// endpoint — browse listings, UDT templates and tag reads must agree byte for byte and value for value. +/// Task 21 runs this against a production press, so the suite is read-only by construction (see +/// ), libplctag and native are never connected at the +/// same time, and the native side runs on one connection held open by an anchor lease. +/// +[Collection(DifferentialCollection.Name)] +[Trait("Category", "LiveIntegration")] +public sealed class TransportDifferentialTests(DifferentialRunFixture fixture) +{ + [Fact] + public async Task Browse_listings_decode_to_identical_tag_sets_through_both_enumerators() + { + var r = await RunOrSkipAsync(); + Log($"## Browse — {r.Target}"); + Log($"libplctag: {r.Libplctag.Browse.Count} tag(s){ErrorSuffix(r.Libplctag.BrowseError)}; " + + $"native: {r.Native.Browse.Count} tag(s){ErrorSuffix(r.Native.BrowseError)}"); + + var onlyLib = DifferentialRun.Missing(r.Libplctag.Browse, r.Native.Browse); + var onlyNative = DifferentialRun.Missing(r.Native.Browse, r.Libplctag.Browse); + foreach (var t in onlyLib) Log($"- only libplctag: {t}"); + foreach (var t in onlyNative) Log($"- only native: {t}"); + + r.Libplctag.BrowseError.ShouldBeNull(); + r.Native.BrowseError.ShouldBeNull(); + r.Libplctag.Browse.ShouldNotBeEmpty("a browse that returns nothing proves nothing"); + onlyLib.ShouldBeEmpty(); + onlyNative.ShouldBeEmpty(); + } + + [Fact] + public async Task Every_template_decodes_to_an_identical_shape_through_both_readers() + { + var r = await RunOrSkipAsync(); + var ids = r.Libplctag.Shapes.Keys.Concat(r.Libplctag.TemplateErrors.Keys) + .Concat(r.Native.Shapes.Keys).Concat(r.Native.TemplateErrors.Keys) + .Distinct().Order().ToList(); + Log($"## Templates — {ids.Count} id(s); truncated by {DifferentialTarget.MaxTemplatesVar}: " + + $"libplctag {r.Libplctag.TemplatesTruncated}, native {r.Native.TemplatesTruncated}"); + if (ids.Count > 0) + { + Log("| Template | libplctag | native | Equal? |"); + Log("|---|---|---|---|"); + } + foreach (var id in ids) + { + var equal = DifferentialRun.ShapesEqual(r.Libplctag.Shapes.GetValueOrDefault(id), r.Native.Shapes.GetValueOrDefault(id)); + Log($"| {id} | {Shape(r.Libplctag, id)} | {Shape(r.Native, id)} | {(equal ? "yes" : "**NO**")} |"); + } + + var problems = DifferentialRun.TemplateProblems(r.Libplctag, r.Native); + foreach (var p in problems) Log($"- PROBLEM: {p}"); + problems.ShouldBeEmpty(); + } + + [Fact] + public async Task Every_selected_tag_reads_identically_through_both_transports() + { + var r = await RunOrSkipAsync(); + Log($"## Reads — {r.Selection.Count} tag(s), {r.Target}"); + Log("Selection by origin: " + string.Join(", ", + r.Selection.GroupBy(t => t.Origin).Select(g => $"{g.Key} {g.Count()}"))); + Log("| Tag | Type | libplctag status | native status | libplctag probe | Equal? |"); + Log("|---|---|---|---|---|---|"); + + var unexpected = new List(); + var expected = new List(); + var live = new List(); + foreach (var tag in r.Selection) + { + var lib = r.Libplctag.Reads[tag.Name]; + var nat = r.Native.Reads[tag.Name]; + var probe = r.Libplctag.ProbeReads.GetValueOrDefault(tag.Name); + var verdict = Compare(tag, lib, nat, probe); + switch (Classify(verdict)) + { + case Verdict.Expected: expected.Add($"{tag.Name}: {verdict}"); break; + case Verdict.Live: live.Add($"{tag.Name} (lib {Detail(lib, full: true)}; lib probe {Detail(probe!, full: true)}; native {Detail(nat, full: true)})"); break; + case Verdict.Unexpected when DifferentialRun.IsLive(lib, probe): + unexpected.Add($"{tag.Name}: {verdict} (live; lib {Detail(lib, full: true)}; lib probe {Detail(probe!, full: true)}; native {Detail(nat, full: true)})"); + break; + case Verdict.Unexpected: unexpected.Add($"{tag.Name}: {verdict} (lib {Detail(lib)}; native {Detail(nat)})"); break; + } + Log($"| `{tag.Name}` | {tag.Type?.ToString() ?? "?"}{(tag.IsArray ? $"[{tag.ElementCount}]" : "")} | " + + $"{StatusName(lib.Status)} | {StatusName(nat.Status)} | {ProbeColumn(lib, probe)} | {verdict} |"); + } + + Log(""); + Log($"Unexpected divergences: {unexpected.Count}; expected divergences: {expected.Count}; " + + $"live tags (value moved between the two libplctag reads; compared by status, length, type code, shape and every " + + $"element that held still): {live.Count} of {r.Selection.Count}."); + foreach (var e in expected) Log($"- expected: {e}"); + foreach (var l in live) Log($"- live: {l}"); + foreach (var u in unexpected) Log($"- UNEXPECTED: {u}"); + + var coverage = DifferentialRun.SelectionCoverageProblems(r.Target, r.Libplctag, r.Selection); + foreach (var c in coverage) Log($"- COVERAGE: {c}"); + + r.Selection.ShouldNotBeEmpty("a run that reads nothing proves nothing"); + unexpected.ShouldBeEmpty(); + coverage.ShouldBeEmpty(); + } + + [Fact] + public async Task Libplctag_and_native_are_never_connected_at_the_same_time() + { + var r = await RunOrSkipAsync(); + Log($"TCP connections to the target: baseline {r.ConnectionsBaseline?.ToString(CultureInfo.InvariantCulture) ?? "unobservable"}, " + + $"before native {r.ConnectionsBeforeNative?.ToString(CultureInfo.InvariantCulture) ?? "unobservable"}, " + + $"libplctag drain wait {r.LibplctagDrainWait.TotalMilliseconds:0} ms."); + if (r.ConnectionsBaseline is null) + Assert.Skip("This OS cannot enumerate TCP connections; the run fell back to a fixed settle before the native phase."); + // The run itself refuses to start the native phase while libplctag's session is open; this pins it. + r.ConnectionsBeforeNative.ShouldNotBeNull(); + r.ConnectionsBeforeNative.Value.ShouldBeLessThanOrEqualTo(r.ConnectionsBaseline.Value); + } + + /// + /// T21 press pass 1 ended in "A callback was made on a garbage collected delegate": libplctag tags whose + /// initialisation failed (NotFound tags, refused latency setups) were left native-side with a live callback. + /// Every libplctag tag this harness creates goes through the driver's adapters, which now destroy such a tag + /// (); this pins that none had to fall back to being rooted instead. + /// + [Fact] + public async Task No_failed_libplctag_tag_is_left_with_a_live_native_callback() + { + await RunOrSkipAsync(); + Log($"libplctag orphans destroyed so far: {LibplctagOrphanRelease.Released}; rooted (unreleasable): {LibplctagOrphanRelease.RootedCount}."); + LibplctagOrphanRelease.CanReadHandles.ShouldBeTrue(); + LibplctagOrphanRelease.RootedCount.ShouldBe(0); + } + + [Fact] + public async Task The_native_side_makes_exactly_one_forward_open_over_the_whole_run() + { + var r = await RunOrSkipAsync(); + if (!r.Target.CountOpens) + Assert.Skip($"{DifferentialTarget.CountOpensVar} is not 1 — the process-wide Forward Open counter is only " + + "trusted when the differential runs alone."); + Log($"Native Forward Opens over the run: {r.NativeForwardOpens}."); + r.NativeForwardOpens.ShouldBe(1); + } + + /// + /// T21 review r1 M9: the native session is closed properly — exactly one Forward Close on the wire (a skipped + /// close, e.g. on a socket already dropped, would otherwise be visible only in a capture), and no connection to + /// the target left once the anchor is released. + /// + [Fact] + public async Task The_native_side_closes_its_one_connection_with_exactly_one_forward_close() + { + var r = await RunOrSkipAsync(); + Log($"Native Forward Closes sent/acked: {r.NativeForwardCloses.Sent}/{r.NativeForwardCloses.Acked}; " + + $"connections after the native phase: {r.ConnectionsAfterNative?.ToString(CultureInfo.InvariantCulture) ?? "unobservable"}."); + if (r.ConnectionsAfterNative is { } after && r.ConnectionsBaseline is { } baseline) + after.ShouldBeLessThanOrEqualTo(baseline); + if (!r.Target.CountOpens) + Assert.Skip($"{DifferentialTarget.CountOpensVar} is not 1 — the process-wide Forward Close counter is only " + + "trusted when the differential runs alone."); + r.NativeForwardCloses.Sent.ShouldBe(1); + } + + /// + /// Offline guard — deliberately NOT gated on the endpoint, so it runs in every build. Fails if any + /// source file of this harness names a write-capable member, and, independently of the source text, + /// if any compiled method of a harness type calls one. + /// + [Fact] + public void The_harness_is_read_only_by_construction() + { + // Built by concatenation so this file's own text never matches itself. + string[] forbidden = + [ + "Write" + "Async", "Encode" + "Value", "WriteBit" + "InDInt", "Send" + "Async", "AbCip" + "Driver", + ]; + // IL half (review r1 M1): method names match by prefix; the driver type matches by declaring type, + // since the driver type's own members are named .ctor / ReadAsync / ..., never the type name. + var forbiddenType = "AbCip" + "Driver"; + + var dir = Path.GetDirectoryName(ThisFile())!; + var sources = Directory.GetFiles(dir, "*.cs"); + sources.ShouldContain(f => Path.GetFileName(f) == nameof(TransportDifferentialTests) + ".cs"); + // Task 21's latency comparison and its run helper are part of the harness the press sees: pinned in scope, + // so moving either out of this folder (or namespace) fails the guard instead of silently escaping it. + sources.ShouldContain(f => Path.GetFileName(f) == nameof(PollLatencyComparisonTests) + ".cs"); + sources.ShouldContain(f => Path.GetFileName(f) == nameof(PollLatencyRun) + ".cs"); + var textHits = (from f in sources + let text = File.ReadAllText(f) + from token in forbidden + where text.Contains(token, StringComparison.Ordinal) + select $"{Path.GetFileName(f)} names {token}").ToList(); + textHits.ShouldBeEmpty(); + + var harnessTypes = typeof(TransportDifferentialTests).Assembly.GetTypes() + .Where(t => t.Namespace == typeof(TransportDifferentialTests).Namespace).ToList(); + harnessTypes.ShouldContain(typeof(TransportDifferentialTests)); + harnessTypes.ShouldContain(typeof(PollLatencyComparisonTests)); + harnessTypes.ShouldContain(typeof(PollLatencyRun)); + var callHits = (from t in harnessTypes + from m in MethodsOf(t) + from callee in CalleesOf(m) + where forbidden.Any(f => callee.Name.StartsWith(f, StringComparison.Ordinal)) + || callee.DeclaringType?.Name.StartsWith(forbiddenType, StringComparison.Ordinal) == true + select $"{t.Name}.{m.Name} calls {callee.DeclaringType?.Name}.{callee.Name}").ToList(); + callHits.ShouldBeEmpty(); + } + + /// + /// Offline falsifiability check of the verdicts: a harness whose comparison could never say NO would + /// pass every live run vacuously. + /// + [Fact] + public void The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch() + { + var dint = new SelectedTag("T", AbCipDataType.DInt, 1, false, "scalar"); + var ok = new ReadObservation((int)AbStatus.Ok, null, [1, 0, 0, 0], 1); + Compare(dint, ok, ok with { }).ShouldBe("yes"); + Compare(dint, ok, ok with { Raw = [2, 0, 0, 0] }).ShouldBe("**NO: raw**"); + Compare(dint, ok, ok with { Raw = null }).ShouldBe("**NO: raw**"); + Compare(dint, ok, ok with { Decoded = 2 }).ShouldBe("**NO: value**"); + Compare(dint, ok, new ReadObservation((int)AbStatus.NotFound, "NotFound", null, null)).ShouldBe("**NO: status**"); + Compare(dint, ok, ok with { Error = "boom" }).ShouldBe("**NO: error**"); + var notFound = new ReadObservation((int)AbStatus.NotFound, "NotFound", null, null); + Compare(dint, notFound, notFound with { }).ShouldBe("yes"); + Compare(dint, ok, new ReadObservation((int)AbStatus.TypeMismatch, null, [1, 0, 0, 0], null)) + .ShouldStartWith("expected"); + // Only that direction is excused: libplctag refusing what native accepts is a real divergence. + Compare(dint, new ReadObservation((int)AbStatus.TypeMismatch, null, [1, 0, 0, 0], null), ok) + .ShouldBe("**NO: status**"); + + var array = new SelectedTag("A", AbCipDataType.Real, 2, true, "array"); + var arr = new ReadObservation((int)AbStatus.Ok, null, new byte[8], new[] { float.NaN, 1f }); + Compare(array, arr, arr with { Decoded = new[] { float.NaN, 1f } }).ShouldBe("yes"); + Compare(array, arr, arr with { Decoded = new[] { float.NaN, 2f } }).ShouldBe("**NO: value**"); + } + + /// + /// Offline pin of the live-drift handling (T21 press pass 1): a value that moved between the two libplctag + /// reads is compared structurally, while a value libplctag read stably and native read differently is still + /// an unexpected divergence. Removing the live handling turns the first assertion into "**NO: raw**". + /// + [Fact] + public void A_live_value_is_compared_structurally_and_a_stable_one_by_value() + { + var real = new SelectedTag("WW_M4ActualTemp", AbCipDataType.Real, 1, false, "scalar"); + ReadObservation Obs(float v) => new((int)AbStatus.Ok, null, BitConverter.GetBytes(v), v); + var lib = Obs(384.88684f); + var probe = Obs(384.95f); + var nat = Obs(385.13782f) with { TypeCode = 0xCA }; + + // Live: the libplctag value moved by itself, so a different native value is not a divergence. + DifferentialRun.IsLive(lib, probe).ShouldBeTrue(); + Compare(real, lib, nat, probe).ShouldBe("live"); + Classify(Compare(real, lib, nat, probe)).ShouldBe(Verdict.Live); + + // ...but everything structural still has to match. + Compare(real, lib, nat with { Status = (int)AbStatus.NotFound, Error = "NotFound" }, probe).ShouldBe("**NO: status**"); + Compare(real, lib, nat with { Raw = new byte[8] }, probe).ShouldBe("**NO: raw length**"); + Compare(real, lib, nat with { Raw = null }, probe).ShouldBe("**NO: raw length**"); + Compare(real, lib with { TypeCode = 0xC4 }, nat, probe).ShouldBe("**NO: type code**"); + Compare(real, lib, nat with { Decoded = 385 }, probe).ShouldBe("**NO: shape**"); + Compare(real, lib, nat with { Decoded = new[] { 385f } }, probe).ShouldBe("**NO: shape**"); + Compare(real, lib, nat with { DecodeError = "boom" }, probe).ShouldBe("**NO: decode**"); + Classify("**NO: raw length**").ShouldBe(Verdict.Unexpected); + + var array = new SelectedTag("A", AbCipDataType.Real, 2, true, "array"); + var arrLib = new ReadObservation((int)AbStatus.Ok, null, new byte[8], new[] { 1f, 2f }); + var arrProbe = arrLib with { Raw = [1, 0, 0, 0, 0, 0, 0, 0], Decoded = new[] { 1.1f, 2f } }; + // Element 0 moved (1 → 1.1), element 1 held at 2: native may read anything finite for 0, but must read 2 for 1. + Compare(array, arrLib, arrLib with { Raw = [2, 0, 0, 0, 0, 0, 0, 0], Decoded = new[] { 3f, 2f } }, arrProbe).ShouldBe("live"); + Compare(array, arrLib, arrLib with { Raw = [2, 0, 0, 0, 0, 0, 0, 0], Decoded = new[] { 3f, 4f } }, arrProbe) + .ShouldBe("**NO: stable element 1**"); + Compare(array, arrLib, arrLib with { Decoded = new[] { 1f, 2f, 3f } }, arrProbe).ShouldBe("**NO: shape**"); + + // Review r1 I1, the pass-1 shape: Real[5] where element 0 (400000) holds still and 1..4 move. A native defect + // that shifts the stable element must FAIL even though the tag is live. + var fout = new SelectedTag("Program:EM01_PowerUnit.__fout9FB1561FA00D73F5", AbCipDataType.Real, 5, true, "udt member"); + ReadObservation Arr(params float[] v) => new((int)AbStatus.Ok, null, v.SelectMany(BitConverter.GetBytes).ToArray(), v); + var foutLib = Arr(400000f, 4.2464733f, 5.2464733f, -6.7535267f, 4.2464733f); + var foutProbe = Arr(400000f, 4.2461f, 5.2461f, -6.7539f, 4.2461f); + Compare(fout, foutLib, Arr(400000f, 4.2457356f, 5.2457356f, -6.7542644f, 4.2457356f), foutProbe).ShouldBe("live"); + var shifted = Compare(fout, foutLib, Arr(4.2457356f, 5.2457356f, -6.7542644f, 4.2457356f, 400000f), foutProbe); + shifted.ShouldBe("**NO: stable element 0**"); + Classify(shifted).ShouldBe(Verdict.Unexpected); + Compare(fout, foutLib, Arr(400000f, float.NaN, 5.2457356f, -6.7542644f, 4.2457356f), foutProbe) + .ShouldBe("**NO: non-finite element 1**"); + Compare(real, lib, nat with { Decoded = float.PositiveInfinity }, probe).ShouldBe("**NO: non-finite element 0**"); + + // Stable: the two libplctag reads agree, so native reading anything else is a real decoding divergence. + var stableProbe = lib with { }; + DifferentialRun.IsLive(lib, stableProbe).ShouldBeFalse(); + Compare(real, lib, nat, stableProbe).ShouldBe("**NO: raw**"); + Classify(Compare(real, lib, nat, stableProbe)).ShouldBe(Verdict.Unexpected); + Compare(real, lib, lib with { Decoded = 1f }, stableProbe).ShouldBe("**NO: value**"); + Compare(real, lib, lib with { }, stableProbe).ShouldBe("yes"); + Classify("yes").ShouldBe(Verdict.Equal); + + // A missing or failed probe is never live: the full value comparison applies. + Compare(real, lib, nat, null).ShouldBe("**NO: raw**"); + Compare(real, lib, nat, new ReadObservation((int)AbStatus.Timeout, "Timeout", null, null)).ShouldBe("**NO: raw**"); + } + + private async Task RunOrSkipAsync() + { + if (DifferentialTarget.FromEnvironment() is null) Assert.Skip(DifferentialTarget.SkipReason); + return await fixture.GetAsync(); + } + + internal enum Verdict { Equal, Expected, Live, Unexpected } + + /// Which list a verdict string belongs to. Live tags are neither expected nor unexpected divergences. + internal static Verdict Classify(string verdict) => + verdict == "yes" ? Verdict.Equal + : verdict == "live" ? Verdict.Live + : verdict.StartsWith("expected", StringComparison.Ordinal) ? Verdict.Expected + : Verdict.Unexpected; + + /// libplctag's second (volatility-probe) read of the tag; null ⇒ treated as stable. + internal static string Compare(SelectedTag tag, ReadObservation lib, ReadObservation nat, ReadObservation? libProbe = null) + { + // Spec §5: native refuses a returned type that disagrees with the authored one; libplctag decodes it anyway. + if (nat.Status == (int)AbStatus.TypeMismatch && lib.Status == (int)AbStatus.Ok) + return "expected (native TypeMismatch, libplctag Ok)"; + if (lib.Status != nat.Status) return "**NO: status**"; + if (!lib.Succeeded || !nat.Succeeded) + return lib.Succeeded == nat.Succeeded ? "yes" : "**NO: error**"; + if (lib.DecodeError is not null || nat.DecodeError is not null) return "**NO: decode**"; + if (DifferentialRun.IsLive(lib, libProbe)) + { + // The value moved between libplctag's own two reads, so native reading a third value proves nothing. + // Everything that does not depend on the value must still agree. + if (nat.Raw is null || nat.Raw.Length != lib.Raw!.Length) return "**NO: raw length**"; + if (lib.TypeCode is { } lt && nat.TypeCode is { } nt && lt != nt) return "**NO: type code**"; + if (tag.Type is null) return "live"; // raw-only (type unknown): nothing decoded to compare element-wise + if (!DifferentialRun.SameShape(lib.Decoded, nat.Decoded)) return "**NO: shape**"; + // Only what MOVED between libplctag's two reads is excused (review r1 I1): every element that held still + // must read the same through native, so a native offset/stride/byte-order defect cannot hide as "live". + return DifferentialRun.LiveElementMismatch(lib.Decoded, libProbe!.Decoded, nat.Decoded) ?? "live"; + } + if (lib.Raw is null || nat.Raw is null || !lib.Raw.AsSpan().SequenceEqual(nat.Raw)) return "**NO: raw**"; + if (tag.Type is not null && !DifferentialRun.ValuesEqual(lib.Decoded, nat.Decoded)) return "**NO: value**"; + return "yes"; + } + + /// Every raw byte and every element (live tags: the evidence must be re-checkable by hand). + private static string Detail(ReadObservation o, bool full = false) => + o.Error ?? o.DecodeError ?? + $"{o.Raw?.Length.ToString(CultureInfo.InvariantCulture) ?? "-"} byte(s) {Hex(o.Raw, full)} = {Show(o.Decoded, full)}"; + + private static string ProbeColumn(ReadObservation lib, ReadObservation? probe) => + probe is null ? "-" + : DifferentialRun.IsLive(lib, probe) ? "**live**" + : probe.Succeeded && lib.Succeeded ? "stable" + : StatusName(probe.Status); + + private static string Hex(byte[]? raw, bool full = false) => + raw is null ? "-" + : full || raw.Length <= 32 ? Convert.ToHexString(raw) + : Convert.ToHexString(raw.AsSpan(0, 32)) + "…"; + + private static string Show(object? v, bool full = false) => v switch + { + null => "null", + Array a => "[" + string.Join(",", a.Cast().Take(full ? int.MaxValue : 8).Select(x => Show(x))) + + (!full && a.Length > 8 ? ",…" : "") + "]", + IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), + _ => v.ToString() ?? "", + }; + + private static string Shape(TransportObservations o, uint id) => + o.TemplateErrors.TryGetValue(id, out var err) ? $"error {err}" + : o.Shapes.TryGetValue(id, out var s) ? s is null ? "undecodable" : $"{s.TypeName} ({s.Members.Count} members, {s.TotalSize} B)" + : "not read"; + + private static string StatusName(int status) => + Enum.IsDefined(typeof(AbStatus), status) ? ((AbStatus)status).ToString() : status.ToString(CultureInfo.InvariantCulture); + + private static string ErrorSuffix(string? error) => error is null ? "" : $" (error: {error})"; + + private static void Log(string line) => TestContext.Current.TestOutputHelper?.WriteLine(line); + + private static string ThisFile([CallerFilePath] string path = "") => path; + + private static IEnumerable MethodsOf(Type t) + { + const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance + | BindingFlags.Static | BindingFlags.DeclaredOnly; + return t.GetMethods(all).Cast().Concat(t.GetConstructors(all)); + } + + /// + /// Every method a body's IL references by token (call / callvirt / newobj / ldftn). Scans every offset + /// for a MethodDef / MemberRef / MethodSpec token — over-inclusive by design: a misaligned hit that + /// happens to resolve can only add a false alarm, never hide a real call. + /// + private static IEnumerable CalleesOf(MethodBase m) + { + var il = m.GetMethodBody()?.GetILAsByteArray(); + if (il is null) yield break; + var typeArgs = m.DeclaringType is { IsGenericType: true } dt ? dt.GetGenericArguments() : null; + var methodArgs = m.IsGenericMethod ? m.GetGenericArguments() : null; + for (var i = 0; i + 4 <= il.Length; i++) + { + var token = BinaryPrimitives.ReadInt32LittleEndian(il.AsSpan(i, 4)); + if ((token >> 24) is not (0x06 or 0x0A or 0x2B)) continue; + MethodBase? callee; + try { callee = m.Module.ResolveMethod(token, typeArgs, methodArgs); } + catch (Exception) { continue; } + if (callee is not null) yield return callee; + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/README.md b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/README.md index 1c9b3844..3a6bad3f 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/README.md +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/README.md @@ -121,6 +121,19 @@ records a skip reason when unreachable, so tests stay green on a fresh clone without the container running. Tests use `[AbServerFact]` / `[AbServerTheory]` which check the same probe. +**Writes are opt-in.** Every write-capable test (today the Logix Emulate ALMD +test, which pulses `SimulateAlarm`) also requires `AB_SERVER_ALLOW_WRITES=1`, +and refuses while `AB_DIFF_ENDPOINT` is set. Simulator / Emulate runs set it; +**never** set it when `AB_SERVER_ENDPOINT` points at production hardware. The +ab_server controllogix matrix itself is read-only and needs no opt-in. + +**Read-only differential (Task 18).** `Differential/` compares libplctag and the +native transport against one endpoint, configured only by `AB_DIFF_*` +variables (see `Differential/DifferentialTarget.cs`). Always run it with +`--filter "FullyQualifiedName~Differential"`, and keep the sibling suites' +discovery probes off-network with `AB_SERVER_ENDPOINT=127.0.0.1:1` when the +target is a real controller. + ## What each family seeds Tag sets match `AbServerProfile.cs` exactly — changing seeds in one diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateAlmdTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateAlmdTests.cs index d33b464d..0be30426 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateAlmdTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateAlmdTests.cs @@ -40,10 +40,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate; public sealed class AbCipEmulateAlmdTests { /// Verifies that real ALMD raise fires OnAlarmEvent through the driver projection. - [AbServerFact] - public async Task Real_ALMD_raise_fires_OnAlarmEvent_through_the_driver_projection() + /// The transport stack the driver runs on. + [AbServerTheory] + [MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))] + public async Task Real_ALMD_raise_fires_OnAlarmEvent_through_the_driver_projection(AbCipTransport transport) { AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate); + IntegrationWriteGate.SkipUnlessWritesAllowed(); // pulses SimulateAlarm on the controller var endpoint = Environment.GetEnvironmentVariable("AB_SERVER_ENDPOINT") ?? throw new InvalidOperationException( @@ -73,9 +76,10 @@ public sealed class AbCipEmulateAlmdTests TagPath: "SimulateAlarm", DataType: AbCipDataType.Bool, Writable: true)), + Transport = transport, }; - await using var drv = new AbCipDriver(options, driverInstanceId: "emulate-almd"); + await using var drv = new AbCipDriver(options, driverInstanceId: $"emulate-almd-{transport}"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var raised = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateNestedUdtTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateNestedUdtTests.cs index 0fcbb9a2..e5df8e7b 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateNestedUdtTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateNestedUdtTests.cs @@ -43,8 +43,10 @@ public sealed class AbCipEmulateNestedUdtTests /// leaves addressable as dot-joined paths, proving the nested template-id threading /// runs the production @udt/{id} CIP fetch against Emulate's firmware. /// - [AbServerFact] - public async Task Nested_struct_member_discovered_and_expanded_via_template_id_fetch() + /// The transport stack the driver runs on. + [AbServerTheory] + [MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))] + public async Task Nested_struct_member_discovered_and_expanded_via_template_id_fetch(AbCipTransport transport) { AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate); @@ -58,9 +60,10 @@ public sealed class AbCipEmulateNestedUdtTests Devices = [new AbCipDeviceOptions($"ab://{endpoint}/1,0")], EnableControllerBrowse = true, Timeout = TimeSpan.FromSeconds(10), + Transport = transport, }; - await using var drv = new AbCipDriver(options, driverInstanceId: "emulate-nested-udt-smoke"); + await using var drv = new AbCipDriver(options, driverInstanceId: $"emulate-nested-udt-smoke-{transport}"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); // Drive controller discovery — the driver walks @tags, encounters MotorNested1 diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateUdtReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateUdtReadTests.cs index a6018c02..16658a70 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateUdtReadTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateUdtReadTests.cs @@ -38,8 +38,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate; public sealed class AbCipEmulateUdtReadTests { /// Verifies that reading a whole UDT decodes each member at its template object offset. - [AbServerFact] - public async Task WholeUdt_read_decodes_each_member_at_its_Template_Object_offset() + /// The transport stack the driver runs on. + [AbServerTheory] + [MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))] + public async Task WholeUdt_read_decodes_each_member_at_its_Template_Object_offset(AbCipTransport transport) { AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate); @@ -63,9 +65,10 @@ public sealed class AbCipEmulateUdtReadTests new AbCipStructureMember("Status", AbCipDataType.DInt), ])), Timeout = TimeSpan.FromSeconds(5), + Transport = transport, }; - await using var drv = new AbCipDriver(options, driverInstanceId: "emulate-udt-smoke"); + await using var drv = new AbCipDriver(options, driverInstanceId: $"emulate-udt-smoke-{transport}"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); // Whole-UDT read optimization from task #194: one libplctag read on the diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/IntegrationWriteGate.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/IntegrationWriteGate.cs new file mode 100644 index 00000000..b316ca54 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/IntegrationWriteGate.cs @@ -0,0 +1,26 @@ +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; + +/// +/// Opt-in gate for every integration test that WRITES to the endpoint under test. A write test runs only +/// when AB_SERVER_ALLOW_WRITES=1, in addition to its own reachability / profile conditions, so +/// pointing an endpoint variable at a real controller (a production press in Task 21) can never write to +/// it by accident. It also refuses while AB_DIFF_ENDPOINT is set, because that variable means a +/// read-only differential run is in progress. The ab_server / simulator runs set the opt-in explicitly. +/// +public static class IntegrationWriteGate +{ + public const string AllowWritesVar = "AB_SERVER_ALLOW_WRITES"; + public const string DifferentialEndpointVar = "AB_DIFF_ENDPOINT"; + + /// Skips the calling test unless writes are explicitly allowed for this run. + public static void SkipUnlessWritesAllowed() + { + if (Environment.GetEnvironmentVariable(DifferentialEndpointVar) is { Length: > 0 }) + Assert.Skip($"Write test refused: {DifferentialEndpointVar} is set, so a read-only differential run is in progress."); + if (Environment.GetEnvironmentVariable(AllowWritesVar) != "1") + Assert.Skip($"Write test: set {AllowWritesVar}=1 to allow writes to the endpoint under test " + + "(simulator runs only — never a production controller)."); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/LibplctagOrphanReleaseLiveTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/LibplctagOrphanReleaseLiveTests.cs new file mode 100644 index 00000000..5c9bf71c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/LibplctagOrphanReleaseLiveTests.cs @@ -0,0 +1,43 @@ +using libplctag.NativeImport; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; + +/// +/// T21 press pass 1 crash, the variant that killed the test host: a tag the controller does not have. libplctag +/// raises Created with NotFound, so libplctag.NET 1.5.2 throws WITHOUT unregistering its callback, and its +/// Dispose() skips the uninitialised tag. Before the fix the native tag survived with a live callback into a +/// delegate the GC was free to collect; libplctag's atexit shutdown then raised DESTROYED into it ("A callback +/// was made on a garbage collected delegate"). This pins that the native tag is destroyed instead. +/// +[Trait("Category", "Integration")] +[Trait("Requires", "AbServer")] +public sealed class LibplctagOrphanReleaseLiveTests +{ + [AbServerFact] + public async Task A_tag_the_controller_lacks_leaves_no_native_tag_behind() + { + var fixture = new AbServerFixture(); + var rt = new LibplctagTagRuntime(new AbCipTagCreateParams( + Gateway: fixture.Host, Port: fixture.Port, CipPath: "1,0", LibplctagPlcAttribute: "controllogix", + TagName: "T21_NoSuchTag", Timeout: TimeSpan.FromSeconds(5))); + try + { + var ex = await Should.ThrowAsync(() => rt.InitializeAsync(CancellationToken.None)); + ex.Status.ShouldBe(AbStatus.NotFound); + rt.GetStatus().ShouldBe((int)AbStatus.NotFound); + + var handle = rt.NativeHandle; + handle.ShouldBeGreaterThan(0); + var exists = plctag.plc_tag_lock(handle) == 0; + if (exists) plctag.plc_tag_unlock(handle); + exists.ShouldBeFalse("a NotFound tag must not survive with its callback registered"); + } + finally + { + rt.Dispose(); + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs new file mode 100644 index 00000000..55b12b85 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs @@ -0,0 +1,37 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; + +/// +/// Crosses every ab_server compose profile with both AbCip transports so each integration +/// test runs once on libplctag and once on the native managed stack. The native run must be green +/// wherever the libplctag run is. +/// +/// +/// Every AbCip compose profile binds host :44818, so only one can run at a time. When +/// AB_SERVER_COMPOSE_PROFILE is set, keeps only that profile, so the +/// rows match the running container instead of driving, say, Micro800 semantics at a ControlLogix. +/// Unset, every profile is crossed (the original behaviour). +/// +public static class TransportMatrix +{ + /// Both transports, for tests that are not parameterised by profile. + public static IReadOnlyList AllTransports { get; } = + [AbCipTransport.Libplctag, AbCipTransport.Native]; + + /// Gets (profile, transport) rows for every selected profile × both transports. + public static IEnumerable Profiles + { + get + { + var only = Environment.GetEnvironmentVariable("AB_SERVER_COMPOSE_PROFILE"); + return from p in KnownProfiles.All + where string.IsNullOrEmpty(only) || + string.Equals(p.ComposeProfile, only, StringComparison.OrdinalIgnoreCase) + from t in AllTransports + select new object[] { p, t }; + } + } + + /// Gets one row per transport. + public static IEnumerable Transports => + AllTransports.Select(t => new object[] { t }); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverCodeReviewRegressionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverCodeReviewRegressionTests.cs index dbc39685..f1fabd0f 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverCodeReviewRegressionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverCodeReviewRegressionTests.cs @@ -1,4 +1,4 @@ -using libplctag; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -323,7 +323,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests // First tag creation → returns error; second → returns success. callCount++; return callCount == 1 - ? new FakeAbCipTag(p) { Status = (int)libplctag.Status.ErrorBadConnection } + ? new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection } : new FakeAbCipTag(p) { Status = 0, Value = 42 }; }; var drv = new AbCipDriver(new AbCipDriverOptions diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverProbeTests.cs index ba406b9f..db596cc3 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverProbeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverProbeTests.cs @@ -1,6 +1,6 @@ using System.Net; using System.Net.Sockets; -using libplctag; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; using Shouldly; using Xunit; @@ -151,27 +151,22 @@ public sealed class AbCipDriverProbeTests // ------------------------------------------------------------------------- /// - /// The exception path classifies by the SAME rule as the status path. libplctag.NET builds - /// with Status.ToString(), so its message is - /// "ErrorNotFound" — this method used to look for the native C spelling "STATUS_NOT_FOUND", - /// which that message can never contain, so a controller that answered "tag does not exist" - /// was reported as a failed handshake. That is the answer every Test Connect gets on a device - /// with no authored tags, i.e. every device at the moment an operator first presses the button. + /// The exception path classifies by the SAME rule as the status path: a controller that answered + /// "tag does not exist" is reachable (the answer every Test Connect gets on a device with no + /// authored tags); a session or transport failure is not. /// - /// The status libplctag threw on. + /// The status the transport threw on. /// Whether the controller should be reported reachable. [Theory] - [InlineData(Status.ErrorNotFound, true)] - [InlineData(Status.ErrorNoMatch, true)] - [InlineData(Status.ErrorBadDevice, true)] - [InlineData(Status.ErrorTimeout, false)] - [InlineData(Status.ErrorBadConnection, false)] - [InlineData(Status.ErrorBadGateway, false)] - [InlineData(Status.ErrorBadParam, false)] - public void ThrownStatus_IsClassifiedTheSameWayAsAReturnedStatus(Status status, bool expectedReachable) + [InlineData(AbStatus.NotFound, true)] + [InlineData(AbStatus.Timeout, false)] + [InlineData(AbStatus.BadConnection, false)] + [InlineData(AbStatus.BadReply, false)] + [InlineData(AbStatus.Partial, false)] + [InlineData(AbStatus.Abort, false)] + public void ThrownStatus_IsClassifiedTheSameWayAsAReturnedStatus(AbStatus status, bool expectedReachable) { - // Exactly how NativeTagWrapper.ThrowIfStatusNotOk constructs it. - var ex = new LibPlcTagException(status.ToString()); + var ex = new AbTransportException(status, status.ToString()); AbCipDriverProbe.IsReachableException(ex).ShouldBe(expectedReachable); AbCipDriverProbe.IsReachableStatus(status).ShouldBe(expectedReachable); @@ -185,5 +180,48 @@ public sealed class AbCipDriverProbeTests [Fact] public void AnUnrecognisedExceptionMessageIsNotTreatedAsReachable() => AbCipDriverProbe.IsReachableException( - new LibPlcTagException("something the wrapper has never said")).ShouldBeFalse(); + new AbTransportException(AbStatus.BadConnection, "something the wrapper has never said")).ShouldBeFalse(); + + /// A fake runtime reporting NotFound is reachable ("probe tag not found"). + [Fact] + public async Task Injected_factory_NotFound_status_is_reported_reachable() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var factory = new FakeAbCipTagFactory + { + Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound }, + }; + + var result = await new AbCipDriverProbe(factory).ProbeAsync( + $"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\"}}]}}", + TimeSpan.FromSeconds(3), + TestContext.Current.CancellationToken); + + result.Ok.ShouldBeTrue(); + result.Message!.ShouldContain("probe tag not found"); + } + + /// + /// T21: the probe builds its handle from the family profile, so a CompactLogix device with no + /// ConnectionSize opens Large (4002) like libplctag; an explicit device ConnectionSize still wins. + /// + [Theory] + [InlineData("", 4002)] + [InlineData(",\"connectionSize\":504", 504)] + public async Task CompactLogix_probe_uses_the_profile_default_unless_the_device_overrides_it(string extra, int expected) + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var factory = new FakeAbCipTagFactory(); + + await new AbCipDriverProbe(factory).ProbeAsync( + $"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\",\"plcFamily\":\"CompactLogix\"{extra}}}]}}", + TimeSpan.FromSeconds(3), + TestContext.Current.CancellationToken); + + factory.Tags.Values.ShouldHaveSingleItem().CreationParams.ConnectionSize.ShouldBe(expected); + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs index 735a9c35..61a78199 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverReadTests.cs @@ -1,4 +1,4 @@ -using libplctag; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -87,14 +87,14 @@ public sealed class AbCipDriverReadTests factory.Tags["Motor1.Speed"].ReadCount.ShouldBe(3); } - /// Verifies that non-zero libplctag status is mapped via AbCipStatusMapper. + /// Verifies that non-zero transport status is mapped via AbCipStatusMapper. [Fact] - public async Task NonZero_libplctag_status_maps_via_AbCipStatusMapper() + public async Task NonZero_transport_status_maps_via_AbCipStatusMapper() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Ghost", "ab://10.0.0.5/1,0", "Missing.Tag", AbCipDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); - factory.Customise = p => new FakeAbCipTag(p) { Status = (int)Status.ErrorNotFound }; + factory.Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound }; var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None); @@ -224,4 +224,24 @@ public sealed class AbCipDriverReadTests snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError); factory.Tags["Nope"].Disposed.ShouldBeTrue(); } + + /// A decode that throws OutOfBounds reads BadOutOfRange through the driver read path. + [Fact] + public async Task Decode_OutOfBounds_exception_maps_to_BadOutOfRange() + { + var (drv, factory) = NewDriver( + new AbCipTagDefinition("Over", "ab://10.0.0.5/1,0", "Over", AbCipDataType.DInt)); + await drv.InitializeAsync("{}", CancellationToken.None); + factory.Customise = p => new ThrowingDecodeTag(p); + + var snapshots = await drv.ReadAsync(["Over"], CancellationToken.None); + + snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange); + } + + private sealed class ThrowingDecodeTag(AbCipTagCreateParams p) : FakeAbCipTag(p) + { + public override object? DecodeValue(AbCipDataType type, int? bitIndex) => + throw new AbTransportException(AbStatus.OutOfBounds, "decode past buffer"); + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverTests.cs index 235e4390..2562cb06 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverTests.cs @@ -106,7 +106,7 @@ public sealed class AbCipDriverTests AbCipPlcFamilyProfile.Micro800.SupportsRequestPacking.ShouldBeFalse(); AbCipPlcFamilyProfile.Micro800.SupportsConnectedMessaging.ShouldBeFalse(); - AbCipPlcFamilyProfile.CompactLogix.DefaultConnectionSize.ShouldBe(504); + AbCipPlcFamilyProfile.CompactLogix.DefaultConnectionSize.ShouldBe(4002); // Large, as libplctag opens it AbCipPlcFamilyProfile.GuardLogix.LibplctagPlcAttribute.ShouldBe("controllogix"); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWholeUdtReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWholeUdtReadTests.cs index b5403ca9..f339b396 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWholeUdtReadTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWholeUdtReadTests.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; using Shouldly; using Xunit; @@ -89,7 +90,7 @@ public sealed class AbCipDriverWholeUdtReadTests // Prime runtime existence via a first (successful) read so we can flip it to error. await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None); - factory.Tags["Motor"].Status = -3; // libplctag BadTimeout — mapped in AbCipStatusMapper + factory.Tags["Motor"].Status = (int)AbStatus.Timeout; // mapped in AbCipStatusMapper var snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs index 2417399b..b70fe900 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs @@ -1,4 +1,4 @@ -using libplctag; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -84,14 +84,14 @@ public sealed class AbCipDriverWriteTests results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); } - /// Verifies that non-zero libplctag status after write maps correctly. + /// Verifies that non-zero transport status after write maps correctly. [Fact] - public async Task Non_zero_libplctag_status_after_write_maps_via_AbCipStatusMapper() + public async Task Non_zero_transport_status_after_write_maps_via_AbCipStatusMapper() { var (drv, factory) = NewDriver( new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.DInt)); await drv.InitializeAsync("{}", CancellationToken.None); - factory.Customise = p => new FakeAbCipTag(p) { Status = (int)Status.ErrorTimeout }; + factory.Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.Timeout }; var results = await drv.WriteAsync( [new WriteRequest("Broken", 1)], CancellationToken.None); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipGatewayCompositionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipGatewayCompositionTests.cs index 6707610f..85d1a38b 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipGatewayCompositionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipGatewayCompositionTests.cs @@ -51,7 +51,7 @@ public sealed class AbCipGatewayCompositionTests [Fact] public void The_symbol_list_browse_tag_carries_the_authored_port() { - using var tag = LibplctagTagEnumerator.BuildSymbolListTag(Params("10.100.0.35", 44999)); + using var tag = LibplctagListingReader.BuildSymbolListTag(Params("10.100.0.35", 44999)); tag.Gateway.ShouldBe("10.100.0.35:44999"); tag.Name.ShouldBe("@tags"); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLocalRefusalTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLocalRefusalTests.cs new file mode 100644 index 00000000..f51bfe18 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLocalRefusalTests.cs @@ -0,0 +1,107 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// Task 22 review r1: a runtime that refuses an operation with (the native +/// transport does this before the wire, e.g. a write it cannot type) reaches the client as BadNotSupported — +/// NOT as a comms fault. The handle is kept (one create, never disposed) and driver health is untouched. +/// +[Trait("Category", "Unit")] +public sealed class AbCipLocalRefusalTests +{ + private const string Host = "ab://10.0.0.5/1,0"; + + private sealed class RefusingTag(AbCipTagCreateParams p, bool refuseRead, bool refuseWrite) : FakeAbCipTag(p) + { + public override Task ReadAsync(CancellationToken cancellationToken) => + refuseRead + ? Task.FromException(new AbTransportException(AbStatus.Unsupported, "refused read")) + : base.ReadAsync(cancellationToken); + + public override Task WriteAsync(CancellationToken cancellationToken) => + refuseWrite + ? Task.FromException(new AbTransportException(AbStatus.Unsupported, "refused write")) + : base.WriteAsync(cancellationToken); + } + + private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory, Func Creates)> NewAsync( + bool refuseRead, bool refuseWrite, AbCipDataType type = AbCipDataType.DInt) + { + var creates = 0; + var factory = new FakeAbCipTagFactory + { + Customise = p => { Interlocked.Increment(ref creates); return new RefusingTag(p, refuseRead, refuseWrite) { Value = 1 }; }, + }; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Host)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", Host, "T", type)), + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + return (drv, factory, () => Volatile.Read(ref creates)); + } + +#pragma warning disable OTOPCUA0001 // the driver's own status mapping is under test, not resilience wrapping + [Fact] + public async Task A_refused_write_is_BadNotSupported_keeps_the_handle_and_leaves_health_alone() + { + var (drv, factory, creates) = await NewAsync(refuseRead: false, refuseWrite: true); + (await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); + var health = drv.GetHealth(); + + (await drv.WriteAsync([new WriteRequest("T", 5)], CancellationToken.None)).Single().StatusCode + .ShouldBe(AbCipStatusMapper.BadNotSupported); + + drv.GetHealth().ShouldBe(health); + factory.Tags["T"].Disposed.ShouldBeFalse(); + (await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbCipStatusMapper.Good); + creates().ShouldBe(1); + } + + [Fact] + public async Task A_refused_read_is_BadNotSupported_keeps_the_handle_and_leaves_health_alone() + { + var (drv, factory, creates) = await NewAsync(refuseRead: true, refuseWrite: false); + var health = drv.GetHealth(); + + for (var n = 0; n < 2; n++) + (await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode + .ShouldBe(AbCipStatusMapper.BadNotSupported); + + drv.GetHealth().ShouldBe(health); + factory.Tags["T"].Disposed.ShouldBeFalse(); + creates().ShouldBe(1); + } + + /// Control: any OTHER transport failure is still a comms fault (evicted, Degraded). + [Fact] + public async Task A_non_refusal_write_failure_still_evicts_and_degrades() + { + var factory = new FakeAbCipTagFactory(); + factory.Customise = p => new ThrowingWriteTag(p) { Value = 1 }; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Host)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", Host, "T", AbCipDataType.DInt)), + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + (await drv.WriteAsync([new WriteRequest("T", 5)], CancellationToken.None)).Single().StatusCode + .ShouldBe(AbCipStatusMapper.BadCommunicationError); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + factory.Tags["T"].Disposed.ShouldBeTrue(); + } +#pragma warning restore OTOPCUA0001 + + private sealed class ThrowingWriteTag(AbCipTagCreateParams p) : FakeAbCipTag(p) + { + public override Task WriteAsync(CancellationToken cancellationToken) => + Task.FromException(new AbTransportException(AbStatus.BadConnection, "socket closed")); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLoggingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLoggingTests.cs index 11123518..1704614b 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLoggingTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLoggingTests.cs @@ -1,3 +1,4 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; using Microsoft.Extensions.Logging; using Shouldly; using Xunit; @@ -88,7 +89,7 @@ public sealed class AbCipLoggingTests var logger = new CapturingLogger(); var factory = new FakeAbCipTagFactory { - Customise = p => new FakeAbCipTag(p) { Status = (int)libplctag.Status.ErrorBadConnection }, + Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection }, }; var drv = new AbCipDriver(new AbCipDriverOptions { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPerDeviceConnectionOptionsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPerDeviceConnectionOptionsTests.cs index 78077e41..04b90233 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPerDeviceConnectionOptionsTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPerDeviceConnectionOptionsTests.cs @@ -117,6 +117,29 @@ public sealed class AbCipPerDeviceConnectionOptionsTests factory.Tags["Speed"].CreationParams.ConnectionSize.ShouldBe(4002); } + /// + /// T21: a CompactLogix device with no override opens Large (4002), matching libplctag's + /// plc=compactlogix; the per-device override still wins. + /// + [Theory] + [InlineData(null, 4002)] + [InlineData(504, 504)] + public async Task CompactLogix_ConnectionSize_defaults_to_large_unless_overridden(int? overrideSize, int expected) + { + var factory = new FakeAbCipTagFactory(); + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Device, AbCipPlcFamily.CompactLogix, ConnectionSize: overrideSize)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt)), + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-1", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.ReadAsync(["Speed"], CancellationToken.None); + + factory.Tags["Speed"].CreationParams.ConnectionSize.ShouldBe(expected); + } + /// Verifies that AllowPacking and ConnectionSize round-trip correctly through JSON parsing. [Fact] public void AbCipDriverFactoryExtensions_ParseOptions_round_trips_AllowPacking_and_ConnectionSize() diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPlcFamilyTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPlcFamilyTests.cs index 13acd500..f42199b3 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPlcFamilyTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipPlcFamilyTests.cs @@ -40,22 +40,26 @@ public sealed class AbCipPlcFamilyTests // ---- CompactLogix ---- - /// Verifies that the CompactLogix profile uses a narrower connection size than ControlLogix. + /// + /// Verifies that the CompactLogix profile defaults to a Large Forward Open (4002), matching what + /// libplctag opens for plc=compactlogix. A target that refuses Large falls back to a regular + /// 504 open inside CipConnection (pinned by the Ab.Transport CipConnectionTests fallback tests). + /// [Fact] - public void CompactLogix_profile_uses_narrower_connection_size() + public void CompactLogix_profile_defaults_to_a_large_forward_open_like_libplctag() { var p = AbCipPlcFamilyProfile.CompactLogix; p.LibplctagPlcAttribute.ShouldBe("compactlogix"); - p.DefaultConnectionSize.ShouldBe(504); // 5069-L3x narrow-window safety + p.DefaultConnectionSize.ShouldBe(4002); // T21: 504 cost the native transport ~3x the round trips per poll p.DefaultCipPath.ShouldBe("1,0"); p.SupportsRequestPacking.ShouldBeTrue(); p.SupportsConnectedMessaging.ShouldBeTrue(); p.MaxFragmentBytes.ShouldBe(500); } - /// Verifies that a CompactLogix device initializes with a narrow connection size. + /// Verifies that a CompactLogix device initializes with the Large connection size libplctag opens (T21). [Fact] - public async Task CompactLogix_device_initialises_with_narrow_ConnectionSize() + public async Task CompactLogix_device_initialises_with_large_ConnectionSize() { var drv = new AbCipDriver(new AbCipDriverOptions { @@ -65,7 +69,7 @@ public sealed class AbCipPlcFamilyTests await drv.InitializeAsync("{}", CancellationToken.None); var profile = drv.GetDeviceState("ab://192.168.1.10/1,0")!.Profile; - profile.DefaultConnectionSize.ShouldBeLessThan(AbCipPlcFamilyProfile.ControlLogix.DefaultConnectionSize); + profile.DefaultConnectionSize.ShouldBe(4002); profile.MaxFragmentBytes.ShouldBeLessThan(AbCipPlcFamilyProfile.ControlLogix.MaxFragmentBytes); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipProgramScopeEnumerationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipProgramScopeEnumerationTests.cs index 53061245..eec9219a 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipProgramScopeEnumerationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipProgramScopeEnumerationTests.cs @@ -30,7 +30,7 @@ public sealed class AbCipProgramScopeEnumerationTests public async Task Program_scoped_tags_are_enumerated_from_each_program_listing() { var reads = new List(); - using var enumerator = new LibplctagTagEnumerator((_, name, _) => + using var enumerator = new SymbolListingEnumerator((_, name, _) => { reads.Add(name); return Task.FromResult(name switch @@ -56,7 +56,7 @@ public sealed class AbCipProgramScopeEnumerationTests [Fact] public async Task A_failing_program_listing_is_skipped() { - using var enumerator = new LibplctagTagEnumerator((_, name, _) => name == "@tags" + using var enumerator = new SymbolListingEnumerator((_, name, _) => name == "@tags" ? Task.FromResult(Convert.FromHexString(ControllerListing)) : Task.FromException(new InvalidOperationException("ErrorNotFound"))); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipStatusMapperTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipStatusMapperTests.cs index dc15dcc7..8c813c63 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipStatusMapperTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipStatusMapperTests.cs @@ -1,4 +1,5 @@ using libplctag; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; @@ -29,41 +30,88 @@ public sealed class AbCipStatusMapperTests AbCipStatusMapper.MapCipGeneralStatus(status).ShouldBe(expected); } - // Driver.AbCip-002 — the integers here are the underlying values of the libplctag.NET - // Status enum (what (int)Tag.GetStatus() actually returns), NOT raw native PLCTAG_ERR_* - // constants. The libplctag.NET wrapper renumbers the native codes into a contiguous enum. - /// Verifies that libplctag Status enum values are mapped to OPC UA status codes. - /// The libplctag Status enum value to map. + /// Verifies that transport status values are mapped to OPC UA status codes. + /// The transport status to map. /// The expected OPC UA status code. [Theory] - [InlineData(Status.Ok, AbCipStatusMapper.Good)] - [InlineData(Status.Pending, AbCipStatusMapper.GoodMoreData)] - [InlineData(Status.ErrorTimeout, AbCipStatusMapper.BadTimeout)] - [InlineData(Status.ErrorNotFound, AbCipStatusMapper.BadNodeIdUnknown)] - [InlineData(Status.ErrorNotAllowed, AbCipStatusMapper.BadNotWritable)] - [InlineData(Status.ErrorOutOfBounds, AbCipStatusMapper.BadOutOfRange)] - [InlineData(Status.ErrorBadConnection, AbCipStatusMapper.BadCommunicationError)] - [InlineData(Status.ErrorBadGateway, AbCipStatusMapper.BadCommunicationError)] - [InlineData(Status.ErrorUnsupported, AbCipStatusMapper.BadNotSupported)] - [InlineData(Status.ErrorNoMem, AbCipStatusMapper.BadCommunicationError)] // unmapped → generic comms - public void MapLibplctagStatus_maps_real_enum_members(Status status, uint expected) + [InlineData(AbStatus.Ok, AbCipStatusMapper.Good)] + [InlineData(AbStatus.Pending, AbCipStatusMapper.GoodMoreData)] + [InlineData(AbStatus.Timeout, AbCipStatusMapper.BadTimeout)] + [InlineData(AbStatus.NotFound, AbCipStatusMapper.BadNodeIdUnknown)] + [InlineData(AbStatus.NotAllowed, AbCipStatusMapper.BadNotWritable)] + [InlineData(AbStatus.OutOfBounds, AbCipStatusMapper.BadOutOfRange)] + [InlineData(AbStatus.TooLarge, AbCipStatusMapper.BadOutOfRange)] + [InlineData(AbStatus.TooSmall, AbCipStatusMapper.BadOutOfRange)] + [InlineData(AbStatus.Unsupported, AbCipStatusMapper.BadNotSupported)] + [InlineData(AbStatus.TypeMismatch, AbCipStatusMapper.BadTypeMismatch)] + [InlineData(AbStatus.DeviceStateConflict, AbCipStatusMapper.BadDeviceFailure)] + [InlineData(AbStatus.BadConnection, AbCipStatusMapper.BadCommunicationError)] + [InlineData(AbStatus.BadReply, AbCipStatusMapper.BadCommunicationError)] + [InlineData(AbStatus.Partial, AbCipStatusMapper.BadCommunicationError)] + [InlineData(AbStatus.Abort, AbCipStatusMapper.BadCommunicationError)] + public void MapStatus_maps_every_AbStatus(AbStatus status, uint expected) { - AbCipStatusMapper.MapLibplctagStatus(status).ShouldBe(expected); - // The int overload must agree — it is the seam IAbCipTagRuntime.GetStatus() drives. - AbCipStatusMapper.MapLibplctagStatus((int)status).ShouldBe(expected); + AbCipStatusMapper.MapStatus(status).ShouldBe(expected); + // The int overload must agree - it is the seam IAbCipTagRuntime.GetStatus() drives. + AbCipStatusMapper.MapStatus((int)status).ShouldBe(expected); } /// Verifies that timeout is distinguished from generic communication error. [Fact] - public void MapLibplctagStatus_distinguishes_timeout_from_generic_comms_error() + public void MapStatus_distinguishes_timeout_from_generic_comms_error() { - // Regression for Driver.AbCip-002: a real timeout used to fall through to - // BadCommunicationError because the code matched the wrong integer (-5). - AbCipStatusMapper.MapLibplctagStatus((int)Status.ErrorTimeout) - .ShouldBe(AbCipStatusMapper.BadTimeout); - AbCipStatusMapper.MapLibplctagStatus((int)Status.ErrorNotFound) + AbCipStatusMapper.MapStatus((int)AbStatus.Timeout).ShouldBe(AbCipStatusMapper.BadTimeout); + AbCipStatusMapper.MapStatus((int)AbStatus.NotFound).ShouldBe(AbCipStatusMapper.BadNodeIdUnknown); + AbCipStatusMapper.MapStatus((int)AbStatus.NotFound).ShouldNotBe(AbCipStatusMapper.BadCommunicationError); + } + + /// Verifies MapException maps transport exceptions by status and everything else to comms error. + [Fact] + public void MapException_uses_the_transport_status() + { + AbCipStatusMapper.MapException(new AbTransportException(AbStatus.NotFound, "x")) .ShouldBe(AbCipStatusMapper.BadNodeIdUnknown); - AbCipStatusMapper.MapLibplctagStatus((int)Status.ErrorNotFound) - .ShouldNotBe(AbCipStatusMapper.BadCommunicationError); + AbCipStatusMapper.MapException(new InvalidOperationException("x")) + .ShouldBe(AbCipStatusMapper.BadCommunicationError); + } + + /// + /// Regression oracle: the OPC UA code the pre-AbStatus MapLibplctagStatus returned for every + /// libplctag member (table copied from the deleted switch) must equal + /// MapStatus(ToAbStatus(member)). + /// + /// A libplctag status. + [Theory] + [MemberData(nameof(AllLibplctagStatuses))] + public void Translation_plus_MapStatus_matches_the_old_libplctag_mapping(Status status) + { + AbCipStatusMapper.MapStatus(LibplctagStatusTranslation.ToAbStatus(status)).ShouldBe(OldMapLibplctagStatus(status)); + } + + /// Every defined libplctag member. + /// One row per member. + public static IEnumerable AllLibplctagStatuses() => + Enum.GetValues().Select(s => new object[] { s }); + + private static uint OldMapLibplctagStatus(Status status) => status switch + { + Status.Ok => AbCipStatusMapper.Good, + Status.Pending => AbCipStatusMapper.GoodMoreData, + Status.ErrorTimeout => AbCipStatusMapper.BadTimeout, + Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbCipStatusMapper.BadNodeIdUnknown, + Status.ErrorNotAllowed => AbCipStatusMapper.BadNotWritable, + Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => AbCipStatusMapper.BadOutOfRange, + Status.ErrorUnsupported or Status.ErrorNotImplemented => AbCipStatusMapper.BadNotSupported, + _ => AbCipStatusMapper.BadCommunicationError, // incl. BadConnection/BadGateway/BadReply/Partial/Abort/... + }; + + /// A decode past the buffer end surfaces as BadOutOfRange once the adapter wraps it. + [Fact] + public void Wrapped_decode_OutOfBounds_maps_to_BadOutOfRange() + { + var ex = Should.Throw(() => + LibplctagStatusTranslation.WrapSync(() => throw new LibPlcTagException("ErrorOutOfBounds"))); + + AbCipStatusMapper.MapException(ex).ShouldBe(AbCipStatusMapper.BadOutOfRange); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportInitLogTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportInitLogTests.cs new file mode 100644 index 00000000..91049efa --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportInitLogTests.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// The transport the instance runs on is logged at every init through the HOST-supplied logger factory, +/// so an operator can tell from the node log which stack serves a driver (T19 live gate). The factory +/// path is the one under test: a driver built without a logger writes to the null logger. +/// +[Trait("Category", "Unit")] +public sealed class AbCipTransportInitLogTests +{ + private static string Config(string? transport) => + $$"""{{{(transport is null ? "" : $"\"transport\":\"{transport}\",")}}"probe":{"enabled":false},"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"ControlLogix"}]}"""; + + /// The init line names the configured-vs-effective transport (Task 22). + [Theory] + [InlineData("Native", "Native (configured)")] + [InlineData("Libplctag", "Libplctag (configured)")] + [InlineData(null, "Native (default)")] + public async Task Init_logs_the_transport_through_the_registered_logger_factory(string? json, string expected) + { + var captured = new List(); + using var factory = new CapturingLoggerFactory(captured); + var driver = AbCipDriverFactoryExtensions.CreateInstance("drv-t19", Config(json), factory); + try + { + await driver.InitializeAsync(Config(json), TestContext.Current.CancellationToken); + } + finally + { + await driver.ShutdownAsync(TestContext.Current.CancellationToken); + } + + captured.ShouldContain(m => m.Contains("drv-t19") && m.Contains($"transport={expected}")); + } + + private sealed class CapturingLoggerFactory(List sink) : ILoggerFactory + { + public ILogger CreateLogger(string categoryName) => new CapturingLogger(sink); + public void AddProvider(ILoggerProvider provider) { } + public void Dispose() { } + } + + private sealed class CapturingLogger(List sink) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + lock (sink) sink.Add(formatter(state, exception)); + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs new file mode 100644 index 00000000..b8bfb43c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs @@ -0,0 +1,263 @@ +using System.Net; +using System.Net.Sockets; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// The per-driver Transport switch: config parsing, driver factory selection and the probe. +[Trait("Category", "Unit")] +public sealed class AbCipTransportSwitchTests +{ + private const string Device = """{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"ControlLogix"}"""; + + private static string Config(string? transportJson) => + $$"""{{{(transportJson is null ? "" : $"\"transport\":{transportJson},")}}"devices":[{{Device}}]}"""; + + /// + /// Transport values select the factory trio; absent parses to null ("use the default") and the driver + /// resolves that to Native (Task 22). An explicit Libplctag still wins. + /// + [Theory] + [InlineData("\"Native\"", AbCipTransport.Native)] + [InlineData("\"native\"", AbCipTransport.Native)] + [InlineData("\"Libplctag\"", AbCipTransport.Libplctag)] + [InlineData(null, AbCipTransport.Native)] + public void Config_transport_selects_the_factory(string? json, AbCipTransport expected) + { + var parsed = AbCipDriverFactoryExtensions.ParseOptions("ab1", Config(json)); + if (json is null) parsed.Transport.ShouldBeNull(); + else parsed.Transport.ShouldBe(expected); + + var driver = AbCipDriverFactoryExtensions.CreateInstance("ab1", Config(json)); + driver.EffectiveTransport.ShouldBe(expected); + if (expected == AbCipTransport.Native) + driver.TagFactory.ShouldBeOfType(); + else + driver.TagFactory.ShouldBeOfType(); + } + + /// The factory trio matches the transport for all three factory kinds. + [Fact] + public void Factories_For_returns_a_matching_trio() + { + var n = AbCipTransportFactories.For(AbCipTransport.Native); + n.Tags.ShouldBeOfType(); + n.Enumerators.ShouldBeOfType(); + n.Templates.ShouldBeOfType(); + + var l = AbCipTransportFactories.For(AbCipTransport.Libplctag); + l.Tags.ShouldBeOfType(); + l.Enumerators.ShouldBeOfType(); + l.Templates.ShouldBeOfType(); + } + + /// A bad value fails the way an existing bad enum field (PlcFamily) does. + [Fact] + public void Bad_transport_fails_like_a_bad_PlcFamily() + { + var bad = Should.Throw( + () => AbCipDriverFactoryExtensions.ParseOptions("ab1", Config("\"Bogus\""))); + bad.Message.ShouldContain("unknown Transport 'Bogus'"); + + var existing = Should.Throw(() => AbCipDriverFactoryExtensions.ParseOptions( + "ab1", """{"devices":[{"hostAddress":"ab://10.0.0.1/1,0","plcFamily":"Bogus"}]}""")); + existing.GetType().ShouldBe(bad.GetType()); + existing.Message.ShouldContain("unknown PlcFamily 'Bogus'"); + } + + /// + /// An undefined integer string is rejected, not silently run on a switch's default arm (Task 22 review + /// r1 M2): Enum.TryParse alone accepts "7". A defined numeric ("1") still parses, as before. + /// + [Fact] + public void An_undefined_numeric_transport_is_rejected() + { + Should.Throw( + () => AbCipDriverFactoryExtensions.ParseOptions("ab1", Config("\"7\""))) + .Message.ShouldContain("unknown Transport '7'"); + AbCipDriverFactoryExtensions.ParseOptions("ab1", Config("\"Native\"")).Transport.ShouldBe(AbCipTransport.Native); + } + + /// A bad driver-level value names the driver config, not a "tag" (Task 22 review r1 M3). + [Fact] + public void A_bad_transport_names_the_driver_config() + { + var ex = Should.Throw( + () => AbCipDriverFactoryExtensions.ParseOptions("ab1", Config("\"Bogus\""))); + ex.Message.ShouldStartWith("AB CIP config in 'ab1' has unknown Transport 'Bogus'"); + } + + /// An explicitly injected factory wins over the option. + [Fact] + public void Injected_factory_wins_over_the_option() + { + var fake = new FakeAbCipTagFactory(); + var driver = new AbCipDriver(new AbCipDriverOptions { Transport = AbCipTransport.Native }, "ab1", fake); + driver.TagFactory.ShouldBeSameAs(fake); + } + + /// + /// Reinit does NOT swap transports (by design): the factories are constructor-held, so a changed + /// transport in the reinit JSON is inert. A deploy recreates the instance instead (#516). + /// + [Fact] + public async Task Reinit_with_a_changed_transport_keeps_the_constructed_transport() + { + var driver = AbCipDriverFactoryExtensions.CreateInstance("ab1", Config("\"Libplctag\"")); + var before = driver.TagFactory; + + await driver.ReinitializeAsync(Config("\"Native\""), TestContext.Current.CancellationToken); + + driver.TagFactory.ShouldBeSameAs(before); + driver.TagFactory.ShouldBeOfType(); + } + + /// + /// The probe's Phase 1 TCP preflight rejects a refused port before any runtime exists, so the native + /// path is proven against a listener that accepts TCP and hangs up: only a runtime that really opens + /// an EtherNet/IP session reports the native transport's own failure text. + /// + [Fact] + public async Task Probe_with_Native_uses_the_native_path() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + _ = Task.Run(async () => + { + while (true) + { + try { (await listener.AcceptTcpClientAsync(TestContext.Current.CancellationToken)).Dispose(); } + catch { return; } + } + }, TestContext.Current.CancellationToken); + + string Cfg(string t) => + $$"""{"transport":"{{t}}","devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0"}]}"""; + var probe = new AbCipDriverProbe(); + + var native = await probe.ProbeAsync(Cfg("Native"), TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + var lib = await probe.ProbeAsync(Cfg("Libplctag"), TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + listener.Stop(); + + native.Ok.ShouldBeFalse(); + native.Message.ShouldNotBeNull(); + native.Message!.ShouldContain("CIP handshake failed: Connection lost"); + lib.Message!.ShouldNotContain("Connection lost"); + } + + /// + /// The probe resolves an ABSENT transport through the same resolver as the driver: with no key it takes + /// the native path (native failure text), and an explicit Libplctag on the same listener does not. + /// + [Fact] + public async Task Probe_with_no_transport_resolves_like_the_driver_to_native() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + _ = Task.Run(async () => + { + while (true) + { + try { (await listener.AcceptTcpClientAsync(TestContext.Current.CancellationToken)).Dispose(); } + catch { return; } + } + }, TestContext.Current.CancellationToken); + + var device = $$"""{"hostAddress":"ab://127.0.0.1:{{port}}/1,0"}"""; + var probe = new AbCipDriverProbe(); + + var absent = await probe.ProbeAsync($$"""{"devices":[{{device}}]}""", TimeSpan.FromSeconds(3), + TestContext.Current.CancellationToken); + var lib = await probe.ProbeAsync($$"""{"transport":"Libplctag","devices":[{{device}}]}""", TimeSpan.FromSeconds(3), + TestContext.Current.CancellationToken); + listener.Stop(); + + AbCipTransportResolver.Resolve(AbCipDriverFactoryExtensions.ParseOptions("p", $$"""{"devices":[{{device}}]}""")) + .ShouldBe(AbCipTransport.Native); + absent.Ok.ShouldBeFalse(); + absent.Message!.ShouldContain("CIP handshake failed: Connection lost"); + lib.Message!.ShouldNotContain("Connection lost"); + } + + /// The probe deserialises with its own string-enum converter; a bad transport is a config error. + [Fact] + public async Task Probe_with_a_bad_transport_reports_invalid_config() + { + var r = await new AbCipDriverProbe().ProbeAsync( + Config("\"Bogus\""), TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + r.Ok.ShouldBeFalse(); + r.Message!.ShouldContain("Config JSON is invalid"); + } + + /// + /// Final review I1 precedence: explicit key > node default > compiled Native. The pinned rows are + /// the falsification rows — a resolver that ignores the node default returns Native for them. + /// + [Theory] + [InlineData(null, AbCipTransport.Libplctag, AbCipTransport.Libplctag)] + [InlineData(null, AbCipTransport.Native, AbCipTransport.Native)] + [InlineData(AbCipTransport.Native, AbCipTransport.Libplctag, AbCipTransport.Native)] + [InlineData(AbCipTransport.Libplctag, AbCipTransport.Native, AbCipTransport.Libplctag)] + public void Explicit_key_beats_node_default_beats_compiled_default( + AbCipTransport? configured, AbCipTransport nodeDefault, AbCipTransport expected) + { + AbCipTransportResolver.Resolve(configured, nodeDefault).ShouldBe(expected); + var json = configured is null ? Config(null) : Config($"\"{configured}\""); + AbCipDriverFactoryExtensions.CreateInstance("ab1", json, loggerFactory: null, defaultTransport: nodeDefault) + .EffectiveTransport.ShouldBe(expected); + } + + /// The init-log rendering distinguishes the compiled default from a node pin. + [Fact] + public void Describe_distinguishes_compiled_default_node_default_and_configured() + { + AbCipTransportResolver.Describe(null).ShouldBe("Native (default)"); + AbCipTransportResolver.Describe(null, AbCipTransport.Libplctag).ShouldBe("Libplctag (node default)"); + AbCipTransportResolver.Describe(AbCipTransport.Native, AbCipTransport.Libplctag).ShouldBe("Native (configured)"); + } + + [Theory] + [InlineData("Native", true)] + [InlineData("libplctag", true)] + [InlineData(" Libplctag ", true)] + [InlineData("1", false)] + [InlineData("Native,Libplctag", false)] + [InlineData("", false)] + [InlineData("Bogus", false)] + public void TryParse_accepts_names_only(string raw, bool ok) => + AbCipTransportResolver.TryParse(raw, out _).ShouldBe(ok); + + /// + /// The probe honours its node default for an absent key: pinned to Libplctag it does NOT produce the + /// native failure text against the same listener the unpinned probe does (falsification control). + /// + [Fact] + public async Task Probe_with_no_transport_uses_the_node_default() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + _ = Task.Run(async () => + { + while (true) + { + try { (await listener.AcceptTcpClientAsync(TestContext.Current.CancellationToken)).Dispose(); } + catch { return; } + } + }, TestContext.Current.CancellationToken); + + var cfg = $$"""{"devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0"}]}"""; + var pinned = await new AbCipDriverProbe(defaultTransport: AbCipTransport.Libplctag) + .ProbeAsync(cfg, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + var unpinned = await new AbCipDriverProbe() + .ProbeAsync(cfg, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + listener.Stop(); + + unpinned.Message!.ShouldContain("CIP handshake failed: Connection lost"); + pinned.Ok.ShouldBeFalse(); + pinned.Message!.ShouldNotContain("Connection lost"); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTypeMismatchTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTypeMismatchTests.cs new file mode 100644 index 00000000..d44485c9 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTypeMismatchTests.cs @@ -0,0 +1,159 @@ +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; +using CapturingLogger = ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.AbCipLoggingTests.CapturingLogger; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// Final review I4: a type mismatch (the controller answered with a type that disagrees with the authored +/// one) is a deterministic configuration refusal, handled like : +/// BadTypeMismatch, the handle is KEPT (one create across N polls), health is unchanged, and the +/// Warning is logged once per tag — not per poll. Before the fix each poll evicted the runtime, so a sole +/// lease holder cost a Forward Close + Forward Open on every poll and the driver sat Degraded. The +/// control tests (a genuine transport failure still evicts and degrades) live in +/// and below. +/// +[Trait("Category", "Unit")] +public sealed class AbCipTypeMismatchTests +{ + private const string Host = "ab://10.0.0.5/1,0"; + private const int Polls = 4; + + /// A fake whose read completes but reports (the native + /// runtime's same-width check), and whose write throws it (the native write gate / CIP 0xFF 0x2107). + private sealed class MismatchTag(AbCipTagCreateParams p) : FakeAbCipTag(p) + { + public override async Task ReadAsync(CancellationToken cancellationToken) + { + await base.ReadAsync(cancellationToken); + Status = (int)AbStatus.TypeMismatch; + } + + public override Task WriteAsync(CancellationToken cancellationToken) => + Task.FromException(new AbTransportException(AbStatus.TypeMismatch, "REAL on the controller, DINT authored")); + } + + private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory, Func Creates, CapturingLogger Log)> + NewAsync() + { + var creates = 0; + var factory = new FakeAbCipTagFactory + { + Customise = p => { Interlocked.Increment(ref creates); return new MismatchTag(p) { Value = 1 }; }, + }; + var log = new CapturingLogger(); + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Host)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", Host, "T", AbCipDataType.DInt, Writable: true)), + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-i4", factory, logger: log); + await drv.InitializeAsync("{}", CancellationToken.None); + return (drv, factory, () => Volatile.Read(ref creates), log); + } + + private static int MismatchWarnings(CapturingLogger log) => + log.Entries.Count(e => e.Level == LogLevel.Warning && e.Message.Contains("logged once per tag")); + +#pragma warning disable OTOPCUA0001 // the driver's own status handling is under test, not resilience wrapping + [Fact] + public async Task A_type_mismatched_read_keeps_the_handle_leaves_health_alone_and_warns_once() + { + var (drv, factory, creates, log) = await NewAsync(); + var health = drv.GetHealth(); + + for (var n = 0; n < Polls; n++) + (await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode + .ShouldBe(AbCipStatusMapper.BadTypeMismatch); + + creates().ShouldBe(1); + factory.Tags["T"].Disposed.ShouldBeFalse(); + factory.Tags["T"].ReadCount.ShouldBe(Polls); // positive control: every poll did reach the runtime + drv.GetHealth().ShouldBe(health); + MismatchWarnings(log).ShouldBe(1); + } + + [Fact] + public async Task A_type_mismatched_write_keeps_the_handle_leaves_health_alone_and_warns_once() + { + var (drv, factory, creates, log) = await NewAsync(); + var health = drv.GetHealth(); + + for (var n = 0; n < Polls; n++) + (await drv.WriteAsync([new WriteRequest("T", 5)], CancellationToken.None)).Single().StatusCode + .ShouldBe(AbCipStatusMapper.BadTypeMismatch); + + creates().ShouldBe(1); + factory.Tags["T"].Disposed.ShouldBeFalse(); + drv.GetHealth().ShouldBe(health); + MismatchWarnings(log).ShouldBe(1); + } + + /// Control: a genuine transport failure on a READ still evicts and degrades. + [Fact] + public async Task A_transport_failure_read_still_evicts_and_degrades() + { + var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection } }; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Host)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", Host, "T", AbCipDataType.DInt)), + Probe = new AbCipProbeOptions { Enabled = false }, + }, "drv-i4", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + (await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode + .ShouldNotBe(AbCipStatusMapper.BadTypeMismatch); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + factory.Tags["T"].Disposed.ShouldBeTrue(); + } + + /// + /// End to end over the REAL native runtime + a private against a fake + /// CIP target that answers every read with a REAL for a DINT-authored tag. The tag is the only lease + /// holder (probe off), so an evict would be the LAST release: Forward Close, then a throttled Forward + /// Open on the next poll. One Forward Open across N polls and no Forward Close proves there is no + /// connection churn. + /// + [Fact] + public async Task A_native_type_mismatch_causes_no_connection_churn() + { + await using var target = new FakeCipTarget(); + // Read Tag Fragmented (0x52) → success reply typed REAL (0x00CA) with 4 data bytes. + target.ConnectedHandler = req => Task.FromResult( + req[0] == 0x52 ? [0xD2, 0x00, 0x00, 0x00, 0xCA, 0x00, 0x00, 0x00, 0x80, 0x3F] : target.ServiceReply(req)); + var pool = new DeviceSessionPool(); + var host = $"ab://127.0.0.1:{target.Port}/1,0"; + var log = new CapturingLogger(); + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", host, "T", AbCipDataType.DInt)), + Probe = new AbCipProbeOptions { Enabled = false }, + Timeout = TimeSpan.FromSeconds(5), + }, "drv-i4-native", new NativeAbCipTagFactory(pool), logger: log); + await drv.InitializeAsync("{}", CancellationToken.None); + var health = drv.GetHealth(); + try + { + for (var n = 0; n < Polls; n++) + (await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode + .ShouldBe(AbCipStatusMapper.BadTypeMismatch); + + target.ForwardOpens.ShouldBe(1); + target.ForwardCloses.ShouldBe(0); + target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(Polls); // positive control: polls hit the wire + drv.GetHealth().ShouldBe(health); + MismatchWarnings(log).ShouldBe(1); + } + finally + { + await drv.ShutdownAsync(CancellationToken.None); + } + } +#pragma warning restore OTOPCUA0001 +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUnknownTagReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUnknownTagReadTests.cs index 15643010..e5aa3c51 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUnknownTagReadTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUnknownTagReadTests.cs @@ -1,4 +1,4 @@ -using libplctag; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.AbCip; @@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// /// A tag the controller does not have is a configuration error, not a lost connection. Seen live /// against a production CompactLogix: with one misspelled tag first in the read list, libplctag's -/// create threw LibPlcTagException("ErrorNotFound"), the driver counted that as a failed +/// create threw AbTransportException(AbStatus.NotFound), the driver counted that as a failed /// Forward-Open, opened the device's connect-backoff window, and every other not-yet-connected tag /// on the device — all of them real — came back BadCommunicationError (throttled). The bad /// tag itself also read BadCommunicationError instead of BadNodeIdUnknown. @@ -43,7 +43,7 @@ public sealed class AbCipUnknownTagReadTests [Fact] public async Task Unknown_tag_does_not_throttle_sibling_tags_and_reads_BadNodeIdUnknown() { - var (drv, _) = Build(name => name == "NoSuchTag_xyz" ? new LibPlcTagException("ErrorNotFound") : null); + var (drv, _) = Build(name => name == "NoSuchTag_xyz" ? new AbTransportException(AbStatus.NotFound, "NotFound") : null); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var r = await drv.ReadAsync(["Typo", "Real1", "Real2"], TestContext.Current.CancellationToken); @@ -61,7 +61,7 @@ public sealed class AbCipUnknownTagReadTests [Fact] public async Task A_connection_failure_still_opens_the_backoff_window() { - var (drv, factory) = Build(name => name == "NoSuchTag_xyz" ? new LibPlcTagException("ErrorTimeout") : null); + var (drv, factory) = Build(name => name == "NoSuchTag_xyz" ? new AbTransportException(AbStatus.Timeout, "Timeout") : null); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); var r = await drv.ReadAsync(["Typo", "Real1"], TestContext.Current.CancellationToken); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectRealControllerCaptureTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectRealControllerCaptureTests.cs index 934ddcbc..db8a4535 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectRealControllerCaptureTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectRealControllerCaptureTests.cs @@ -16,17 +16,17 @@ public sealed class CipSymbolObjectRealControllerCaptureTests { // Controller scope, press: WW_InCycle (BOOL), Inf_LookupFwkB (UDT[23], template 3386), // Program:UN01_Press (the program entry itself, type 0x1068). - private const string WwInCycle = "AF010000C10001000000000000000000000000000A0057575F496E4379636C65"; - private const string InfLookupFwkB = "6D0000003AAD60001700000000000000000000000E00496E665F4C6F6F6B757046776B42"; - private const string ProgramUn01 = "7900000068100000000000000000000000000000120050726F6772616D3A554E30315F5072657373"; + internal const string WwInCycle = "AF010000C10001000000000000000000000000000A0057575F496E4379636C65"; + internal const string InfLookupFwkB = "6D0000003AAD60001700000000000000000000000E00496E665F4C6F6F6B757046776B42"; + internal const string ProgramUn01 = "7900000068100000000000000000000000000000120050726F6772616D3A554E30315F5072657373"; // Controller scope, second controller: arrSysTime (DINT[7]), arrMessages (STRING[50]). - private const string ArrSysTime = "11000000C42004000700000000000000000000000A0061727253797354696D65"; - private const string ArrMessages = "61000000CEAF58003200000000000000000000000B006172724D65737361676573"; + internal const string ArrSysTime = "11000000C42004000700000000000000000000000A0061727253797354696D65"; + internal const string ArrMessages = "61000000CEAF58003200000000000000000000000B006172724D65737361676573"; // Program:UN01_Press.@tags: names arrive WITHOUT the Program: prefix. - private const string OutEState = "1A000000C40004000000000000000000000000000A004F75745F655374617465"; - private const string DisableStates = "4C000000C42004002000000000000000000000000E005F44697361626C65537461746573"; + internal const string OutEState = "1A000000C40004000000000000000000000000000A004F75745F655374617465"; + internal const string DisableStates = "4C000000C42004002000000000000000000000000E005F44697361626C65537461746573"; private static List Decode(params string[] entries) => CipSymbolObjectDecoder.Decode(Convert.FromHexString(string.Concat(entries))).ToList(); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagEmptyPathTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagEmptyPathTests.cs new file mode 100644 index 00000000..4cd93ba5 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagEmptyPathTests.cs @@ -0,0 +1,51 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// libplctag rejects an empty-string path with ErrorBadData; "no route" (Micro800, or any +/// unrouted device authored as ab://host:44818/) must reach it as null. Pre-dates the native transport. +/// +public sealed class LibplctagEmptyPathTests +{ + private static AbCipTagCreateParams Params(string cipPath) => + new(Gateway: "10.100.0.35", Port: 44818, CipPath: cipPath, LibplctagPlcAttribute: "micro800", + TagName: "TestDINT", Timeout: TimeSpan.FromSeconds(5)); + + [Theory] + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData(null, null)] + [InlineData("1,0", "1,0")] + public void Normalize_maps_blank_to_null_and_keeps_a_route(string? input, string? expected) => + LibplctagPath.Normalize(input).ShouldBe(expected); + + [Fact] + public void The_data_tag_omits_the_path_for_an_empty_route() + { + using var tag = LibplctagTagRuntime.BuildTag(Params("")); + tag.Path.ShouldBeNull(); + } + + [Fact] + public void The_data_tag_keeps_a_real_route() + { + using var tag = LibplctagTagRuntime.BuildTag(Params("1,0")); + tag.Path.ShouldBe("1,0"); + } + + [Fact] + public void The_symbol_list_tag_omits_the_path_for_an_empty_route() + { + using var tag = LibplctagListingReader.BuildSymbolListTag(Params("")); + tag.Path.ShouldBeNull(); + } + + [Fact] + public void The_template_tag_omits_the_path_for_an_empty_route() + { + using var tag = LibplctagTemplateReader.BuildTemplateTag(Params(""), templateInstanceId: 1); + tag.Path.ShouldBeNull(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagOrphanReleaseTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagOrphanReleaseTests.cs new file mode 100644 index 00000000..3d1b3a62 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagOrphanReleaseTests.cs @@ -0,0 +1,119 @@ +using libplctag.NativeImport; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// T21 press pass 1: a libplctag tag whose initialisation FAILED used to leave its native tag behind, because +/// libplctag.NET 1.5.2's Dispose() only destroys an initialised tag. Its callback delegate then died with +/// the wrapper, and libplctag's atexit shutdown raised DESTROYED into it: "A callback was made on a garbage +/// collected delegate". These pin that a failed initialisation destroys the native tag before it propagates. +/// The NotFound variant (the one that crashed, callback still registered) needs a controller and lives in +/// AbCip.IntegrationTests/LibplctagOrphanReleaseLiveTests; this offline one is the refused-connection +/// variant, whose native tag otherwise keeps reconnecting in the background for the life of the process. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class LibplctagOrphanReleaseCollection +{ + /// The release counters and the rooting seam are process-wide: nothing else may run alongside. + public const string Name = "libplctag orphan release"; +} + +[Collection(LibplctagOrphanReleaseCollection.Name)] +public sealed class LibplctagOrphanReleaseTests +{ + private static AbCipTagCreateParams Refused(string tagName = "TestDINT") => new( + Gateway: "127.0.0.1", Port: 1, CipPath: "1,0", LibplctagPlcAttribute: "controllogix", + TagName: tagName, Timeout: TimeSpan.FromMilliseconds(300)); + + [Fact] + public void The_pinned_libplctag_wrapper_still_exposes_the_native_handle() + { + // A libplctag bump that renames Tag._tag / NativeTagWrapper.nativeTagHandle disables the release (the tag is + // then rooted for the process lifetime instead). Fail here so the bump is reviewed against the release. + LibplctagOrphanRelease.CanReadHandles.ShouldBeTrue(); + } + + /// + /// Final review M2: the handle field's TYPE is pinned explicitly, not just its presence. A retype (IntPtr, + /// long) now degrades to the rooting fallback instead of an InvalidCastException inside a catch block, + /// and this names the reason when it happens. + /// + [Fact] + public void The_pinned_wrapper_handle_field_is_still_an_int() + { + const System.Reflection.BindingFlags priv = + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance; + var wrapper = typeof(libplctag.Tag).GetField("_tag", priv).ShouldNotBeNull(); + wrapper.FieldType.GetField("nativeTagHandle", priv).ShouldNotBeNull().FieldType.ShouldBe(typeof(int)); + } + + /// HandleOf never throws (it runs inside the runtimes' catch blocks); a never-created tag reads 0. + [Fact] + public void HandleOf_a_fresh_tag_is_zero_and_does_not_throw() + { + using var tag = new libplctag.Tag(); + LibplctagOrphanRelease.HandleOf(tag).ShouldBe(0); + } + + [Fact] + public async Task A_failed_initialisation_destroys_the_native_tag_it_created() + { + // Port 1 on loopback refuses the connection; libplctag keeps retrying until the timeout. + var rt = new LibplctagTagRuntime(new AbCipTagCreateParams( + Gateway: "127.0.0.1", Port: 1, CipPath: "1,0", LibplctagPlcAttribute: "controllogix", + TagName: "TestDINT", Timeout: TimeSpan.FromMilliseconds(300))); + try + { + await Should.ThrowAsync(() => rt.InitializeAsync(CancellationToken.None)); + var handle = rt.NativeHandle; + handle.ShouldBeGreaterThan(0, "plc_tag_create_ex returned a handle before the failure"); + NativeTagExists(handle).ShouldBeFalse("the failed tag's native handle must be destroyed, not orphaned"); + // The status the tag held is kept, not replaced by the destroyed handle's NotFound. + rt.GetStatus().ShouldNotBe((int)AbStatus.NotFound); + // Review r1 M2: the wrapper no longer holds the destroyed id, so a repeat release cannot touch it. + rt.WrapperHandle.ShouldBe(0); + } + finally + { + rt.Dispose(); + } + } + + /// + /// Review r1 M1: when the handle cannot be read (a renamed wrapper field), the failed tag is ROOTED — its callback + /// delegate can then never be collected — and nothing is destroyed. + /// + [Fact] + public async Task Without_a_readable_handle_the_failed_tag_is_rooted_instead() + { + var rooted = LibplctagOrphanRelease.RootedCount; + var released = LibplctagOrphanRelease.Released; + var rt = new LibplctagTagRuntime(Refused()); + LibplctagOrphanRelease.ForceRootingForTest = true; + try + { + await Should.ThrowAsync(() => rt.InitializeAsync(CancellationToken.None)); + LibplctagOrphanRelease.RootedCount.ShouldBe(rooted + 1); + LibplctagOrphanRelease.Released.ShouldBe(released); + } + finally + { + LibplctagOrphanRelease.ForceRootingForTest = false; + // Clean up the native tag this test deliberately leaked (its callback was unregistered by the timeout). + var handle = rt.NativeHandle; + if (handle > 0) plctag.plc_tag_destroy(handle); + rt.Dispose(); + } + } + + /// plc_tag_lock answers PLCTAG_ERR_NOT_FOUND for a handle the library no longer holds. + internal static bool NativeTagExists(int handle) + { + if (plctag.plc_tag_lock(handle) != 0) return false; + plctag.plc_tag_unlock(handle); + return true; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs new file mode 100644 index 00000000..67751521 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs @@ -0,0 +1,54 @@ +using libplctag; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// Pins the libplctag to translation table. +public sealed class LibplctagStatusTranslationTests +{ + /// Each libplctag status lands in the documented bucket. + /// The libplctag status. + /// The expected transport status. + [Theory] + [InlineData(Status.Ok, AbStatus.Ok)] + [InlineData(Status.Pending, AbStatus.Pending)] + [InlineData(Status.ErrorTimeout, AbStatus.Timeout)] + [InlineData(Status.ErrorNotFound, AbStatus.NotFound)] + [InlineData(Status.ErrorNoMatch, AbStatus.NotFound)] + [InlineData(Status.ErrorBadDevice, AbStatus.NotFound)] + [InlineData(Status.ErrorNotAllowed, AbStatus.NotAllowed)] + [InlineData(Status.ErrorOutOfBounds, AbStatus.OutOfBounds)] + [InlineData(Status.ErrorTooLarge, AbStatus.TooLarge)] + [InlineData(Status.ErrorTooSmall, AbStatus.TooSmall)] + [InlineData(Status.ErrorUnsupported, AbStatus.Unsupported)] + [InlineData(Status.ErrorNotImplemented, AbStatus.Unsupported)] + [InlineData(Status.ErrorPartial, AbStatus.Partial)] + [InlineData(Status.ErrorAbort, AbStatus.Abort)] + [InlineData(Status.ErrorBadReply, AbStatus.BadReply)] + [InlineData(Status.ErrorBadGateway, AbStatus.BadConnection)] + [InlineData(Status.ErrorWinsock, AbStatus.BadConnection)] + public void Translates(Status from, AbStatus to) => LibplctagStatusTranslation.ToAbStatus(from).ShouldBe(to); + + /// The exception message is recovered as the enum name. + [Fact] + public void Exception_message_is_recovered_as_the_enum_name() + { + LibplctagStatusTranslation.ToAbStatus(new LibPlcTagException("ErrorNotFound")).ShouldBe(AbStatus.NotFound); + LibplctagStatusTranslation.ToAbStatus(new LibPlcTagException("gibberish")).ShouldBe(AbStatus.BadConnection); + } + + /// Wrap rethrows as a transport exception keeping the original as InnerException. + [Fact] + public async Task Wrap_rethrows_as_AbTransportException_with_inner() + { + var inner = new LibPlcTagException("ErrorTimeout"); + + var ex = await Should.ThrowAsync( + () => LibplctagStatusTranslation.Wrap(() => throw inner)); + + ex.Status.ShouldBe(AbStatus.Timeout); + ex.InnerException.ShouldBeSameAs(inner); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagEnumeratorTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagEnumeratorTests.cs new file mode 100644 index 00000000..c2f2a445 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagEnumeratorTests.cs @@ -0,0 +1,212 @@ +using System.Buffers.Binary; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// Task 13: the native symbol enumerator over , serving the real +/// controller listing entries (rev 34.11) across pages. The decoded tag list must equal decoding +/// the captured libplctag buffer directly. +/// +[Trait("Category", "Unit")] +public sealed class NativeAbCipTagEnumeratorTests +{ + private const string ControllerEntries = + CipSymbolObjectRealControllerCaptureTests.WwInCycle + CipSymbolObjectRealControllerCaptureTests.ProgramUn01 + + CipSymbolObjectRealControllerCaptureTests.InfLookupFwkB + CipSymbolObjectRealControllerCaptureTests.ArrSysTime + + CipSymbolObjectRealControllerCaptureTests.ArrMessages; + + private static readonly AbCipTagCreateParams Params = new( + Gateway: "10.0.0.5", Port: 44818, CipPath: "1,0", LibplctagPlcAttribute: "controllogix", + TagName: "@tags", Timeout: TimeSpan.FromSeconds(2)); + + /// Splits concatenated entries into one-entry byte arrays. + private static List Entries(string hex) + { + var b = Convert.FromHexString(hex); + var list = new List(); + for (var p = 0; p < b.Length;) + { + var nameLen = BinaryPrimitives.ReadUInt16LittleEndian(b.AsSpan(p + 20)); + var len = 22 + nameLen; + list.Add(b[p..(p + len)]); + p += len; + } + // A controller lists instances in ascending order; paging continues from last + 1. + return [.. list.OrderBy(e => BinaryPrimitives.ReadUInt32LittleEndian(e))]; + } + + private static byte[] Concat(List entries) => [.. entries.SelectMany(e => e)]; + + /// Answers 0x55 listing requests from (keyed by program or null), two entries a page. + private static FakeCipChannel Serve(Dictionary> listings, int perPage = 2) + { + var ch = new FakeCipChannel(); + ch.RespondPerService = req => + { + req[0].ShouldBe((byte)0x55); + // path: [0x91 len name pad]? 20 6B 25|26 00 instance + var pathBytes = req[1] * 2; + var path = req.AsSpan(2, pathBytes); + var key = ""; + if (path[0] == 0x91) + { + var n = path[1]; + key = System.Text.Encoding.ASCII.GetString(path.Slice(2, n))["Program:".Length..]; + path = path[(2 + n + (n % 2))..]; + } + uint start = path[2] == 0x25 + ? BinaryPrimitives.ReadUInt16LittleEndian(path[4..]) + : BinaryPrimitives.ReadUInt32LittleEndian(path[4..]); + if (!listings.TryGetValue(key, out var all)) return FakeCipChannel.Reply(0x55, 0x05, []); + var page = all.Where(e => BinaryPrimitives.ReadUInt32LittleEndian(e) >= start).Take(perPage).ToList(); + var more = all.Any(e => BinaryPrimitives.ReadUInt32LittleEndian(e) > + BinaryPrimitives.ReadUInt32LittleEndian(page[^1])); + return FakeCipChannel.Reply(0x55, more ? (byte)0x06 : (byte)0x00, [.. page.SelectMany(e => e)]); + }; + return ch; + } + + private static async Task> EnumerateAsync(FakeCipChannel ch, Action onRelease) + { + await using var sched = new RequestScheduler(ch, allowPacking: false); + using var enumerator = new NativeAbCipTagEnumerator(_ => (sched, () => { onRelease(); return ValueTask.CompletedTask; })); + var tags = new List(); + await foreach (var t in enumerator.EnumerateAsync(Params, TestContext.Current.CancellationToken)) + tags.Add(t); + return tags; + } + + [Fact] + public async Task Paged_controller_and_program_listings_decode_identically_to_the_captured_buffers() + { + var ch = Serve(new() + { + [""] = Entries(ControllerEntries), + ["UN01_Press"] = Entries(CipSymbolObjectRealControllerCaptureTests.OutEState), + }); + var released = 0; + + var native = await EnumerateAsync(ch, () => released++); + + var expected = new List(); + expected.AddRange(CipSymbolObjectDecoder.Decode(Concat(Entries(ControllerEntries)))); + expected.AddRange(CipSymbolObjectDecoder.Decode( + Convert.FromHexString(CipSymbolObjectRealControllerCaptureTests.OutEState), programScope: "UN01_Press")); + native.Count.ShouldBe(expected.Count); + for (var i = 0; i < expected.Count; i++) + { + native[i].Name.ShouldBe(expected[i].Name); + native[i].DataType.ShouldBe(expected[i].DataType); + native[i].IsArray.ShouldBe(expected[i].IsArray); + native[i].ElementCount.ShouldBe(expected[i].ElementCount); + native[i].TemplateInstanceId.ShouldBe(expected[i].TemplateInstanceId); + native[i].ProgramScope.ShouldBe(expected[i].ProgramScope); + } + native.ShouldContain(t => t.Name == "Out_eState" && t.ProgramScope == "UN01_Press"); + // Five controller entries at two a page = 3 pages; one program page. Pages continue at last + 1. + ch.Sent.Count.ShouldBe(4); + released.ShouldBe(1); + } + + [Fact] + public async Task A_failing_program_listing_is_skipped_but_the_lease_is_still_released() + { + var ch = Serve(new() { [""] = Entries(ControllerEntries) }); // program listing answers 0x05 + var released = 0; + + var native = await EnumerateAsync(ch, () => released++); + + native.Select(t => t.Name).ShouldContain("WW_InCycle"); + released.ShouldBe(1); + } + + [Fact] + public async Task The_reader_pages_from_last_instance_plus_one_and_returns_the_concatenated_bytes() + { + var ch = Serve(new() { [""] = Entries(ControllerEntries) }); + await using var sched = new RequestScheduler(ch, allowPacking: false); + + var bytes = await NativeListingReader.ReadAsync(sched, "@tags", TestContext.Current.CancellationToken); + + bytes.ShouldBe(Concat(Entries(ControllerEntries))); + } + + private static FakeCipChannel RepeatingPartialPage(uint lastInstance) + { + var ch = new FakeCipChannel(); + // One entry (instance = lastInstance, name "A") replied with "more" every time. + var entry = new byte[22 + 1]; + BinaryPrimitives.WriteUInt32LittleEndian(entry, lastInstance); + BinaryPrimitives.WriteUInt16LittleEndian(entry.AsSpan(20), 1); + entry[22] = (byte)'A'; + ch.RespondPerService = _ => FakeCipChannel.Reply(0x55, 0x06, entry); + return ch; + } + + [Fact] + public async Task A_partial_page_that_never_advances_fails_fast_instead_of_looping() + { + // Instance 5 is repeated forever: page 2 starts at 6 but answers last = 5 (< start). + var ch = RepeatingPartialPage(5); + await using var sched = new RequestScheduler(ch, allowPacking: false); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(10)); + + var ex = await Should.ThrowAsync( + () => NativeListingReader.ReadAsync(sched, "@tags", cts.Token)); + + ex.Status.ShouldBe(AbStatus.BadReply); + ch.SentCount.ShouldBeLessThan(5); + } + + [Fact] + public async Task A_partial_page_ending_at_the_maximum_instance_is_rejected_rather_than_wrapping() + { + var ch = RepeatingPartialPage(uint.MaxValue); + await using var sched = new RequestScheduler(ch, allowPacking: false); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(10)); + + var ex = await Should.ThrowAsync( + () => NativeListingReader.ReadAsync(sched, "@tags", cts.Token)); + + ex.Status.ShouldBe(AbStatus.BadReply); + ch.SentCount.ShouldBe(1); + } + + [Fact] + public async Task Cancelling_mid_enumeration_releases_the_lease() + { + var ch = Serve(new() { [""] = Entries(ControllerEntries) }); + ch.Gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var sched = new RequestScheduler(ch, allowPacking: false); + var released = 0; + using var enumerator = new NativeAbCipTagEnumerator(_ => (sched, () => { released++; return ValueTask.CompletedTask; })); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + + var run = Task.Run(async () => + { + await foreach (var _ in enumerator.EnumerateAsync(Params, cts.Token)) { } + }, TestContext.Current.CancellationToken); + await ch.WaitForSendsAsync(1); + cts.Cancel(); + + await Should.ThrowAsync(() => run); + released.ShouldBe(1); + ch.Gate.TrySetResult(); + } + + [Fact] + public void A_throwing_release_delegate_never_escapes_ReleaseBounded() + { + NativeLease.ReleaseBounded(() => throw new InvalidOperationException("boom"), + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + NativeLease.ReleaseBounded(() => ValueTask.FromException(new ObjectDisposedException("x")), + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs new file mode 100644 index 00000000..2e6e6b5d --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs @@ -0,0 +1,411 @@ +using System.Text; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// Task 12: the native tag runtime over . Reply buffers are hard-coded +/// bytes in the layout libplctag hands its getters (the Read Tag Fragmented reply data after the +/// type header), so each decode assertion is the value would return +/// for the same buffer. +/// +public sealed class NativeAbCipTagRuntimeTests +{ + private static AbCipTagCreateParams Params( + string tag, AbCipDataType? type, int elementCount = 1, bool isArray = false, + string plc = "controllogix", string cipPath = "1,0", bool connected = true) => new( + Gateway: "10.0.0.5", + Port: 44818, + CipPath: cipPath, + LibplctagPlcAttribute: plc, + TagName: tag, + Timeout: TimeSpan.FromSeconds(2), + AllowPacking: false, + ElementCount: elementCount, + IsArray: isArray, + AuthoredType: type, + UseConnectedMessaging: connected); + + /// A Read Tag Fragmented (0xD2) success reply: type code (+ handle) then data. + private static FakeCipChannel Replying(string typeHex, string dataHex) => new() + { + Respond = _ => Task.FromResult(Convert.FromHexString("D2000000" + typeHex + dataHex)), + }; + + private static async Task ReadAsync(FakeCipChannel ch, AbCipTagCreateParams p) + { + var rt = NativeAbCipTagRuntime.ForTest(ch, p); + await rt.InitializeAsync(CancellationToken.None); + await rt.ReadAsync(CancellationToken.None); + return rt; + } + + [Theory] + [InlineData(AbCipDataType.Bool, "C100", "01", true)] + [InlineData(AbCipDataType.SInt, "C200", "FE", -2)] + [InlineData(AbCipDataType.USInt, "C600", "FE", 254)] + [InlineData(AbCipDataType.Int, "C300", "FEFF", -2)] + [InlineData(AbCipDataType.UInt, "C700", "FEFF", 65534)] + [InlineData(AbCipDataType.DInt, "C400", "FEFFFFFF", -2)] + [InlineData(AbCipDataType.UDInt, "C800", "FEFFFFFF", 4294967294u)] + [InlineData(AbCipDataType.LInt, "C500", "FEFFFFFFFFFFFFFF", -2L)] + [InlineData(AbCipDataType.ULInt, "C900", "FEFFFFFFFFFFFFFF", 18446744073709551614UL)] + [InlineData(AbCipDataType.Real, "CA00", "0000803F", 1f)] + [InlineData(AbCipDataType.LReal, "CB00", "000000000000F03F", 1d)] + [InlineData(AbCipDataType.Dt, "C400", "40E20100", 123456)] + public async Task Each_atomic_type_decodes_exactly_as_libplctag_would( + AbCipDataType type, string typeHex, string dataHex, object expected) + { + using var rt = await ReadAsync(Replying(typeHex, dataHex), Params("T", type)); + rt.GetStatus().ShouldBe((int)AbStatus.Ok); + rt.GetSize().ShouldBe(dataHex.Length / 2); + rt.DecodeValue(type, null).ShouldBe(expected); + } + + [Fact] + public async Task A_Logix_STRING_decodes_LEN_then_ASCII_DATA() + { + // Structure 0x02A0 + handle 0x0FCE, then LEN=7 "HelloAB" in an 88-byte STRING. + var data = "07000000" + Convert.ToHexString(Encoding.ASCII.GetBytes("HelloAB")) + new string('0', 2 * 77); + using var rt = await ReadAsync(Replying("A002CE0F", data), Params("S", AbCipDataType.String)); + rt.DecodeValue(AbCipDataType.String, null).ShouldBe("HelloAB"); + rt.StructHandle.ShouldBe((ushort)0x0FCE); + } + + [Fact] + public async Task A_STRING_whose_LEN_overruns_the_buffer_is_clamped_to_the_data() + { + using var rt = await ReadAsync(Replying("A002CE0F", "FF000000414243"), Params("S", AbCipDataType.String)); + rt.DecodeValue(AbCipDataType.String, null).ShouldBe("ABC"); + } + + [Fact] + public async Task A_bit_index_reads_that_bit_of_the_little_endian_buffer() + { + // 0x00000100 → bit 8 set, bit 3 clear. + using var rt = await ReadAsync(Replying("C400", "00010000"), Params("Flags", null)); + rt.DecodeValue(AbCipDataType.Bool, 8).ShouldBe(true); + rt.DecodeValue(AbCipDataType.Bool, 3).ShouldBe(false); + } + + [Fact] + public async Task A_BOOL_bit_selector_against_a_DINT_host_is_not_a_type_mismatch() + { + var ch = Replying("C400", "08000000"); + using var rt = await ReadAsync(ch, Params("Flags.3", AbCipDataType.Bool)); + rt.ImplicitBit.ShouldBe(3); + rt.GetStatus().ShouldBe((int)AbStatus.Ok); + rt.DecodeValue(AbCipDataType.Bool, 3).ShouldBe(true); + // The bit selector is stripped from the encoded path: the request names only "Flags". + ch.Sent[0].ShouldBe(Convert.FromHexString("5204" + "9105466C61677300" + "0100" + "00000000")); + } + + [Fact] + public async Task An_authored_BOOL_answered_as_a_packed_BOOL_array_DWORD_is_not_a_type_mismatch() + { + using var rt = await ReadAsync(Replying("D300", "01000000"), Params("Bits[0]", AbCipDataType.Bool)); + rt.GetStatus().ShouldBe((int)AbStatus.Ok); + rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true); + } + + [Fact] + public async Task Real_returned_for_a_DInt_authored_tag_is_TypeMismatch() + { + using var rt = await ReadAsync(Replying("CA00", "0000803F"), Params("Speed", AbCipDataType.DInt)); + rt.GetStatus().ShouldBe((int)AbStatus.TypeMismatch); + rt.DecodeValue(AbCipDataType.DInt, null).ShouldBeNull(); + rt.DecodeValueAt(AbCipDataType.DInt, 0, null).ShouldBeNull(); + rt.DecodeArray(AbCipDataType.DInt, 1).ShouldBeNull(); + } + + [Fact] + public async Task A_structure_reply_is_never_a_type_mismatch() + { + using var rt = await ReadAsync(Replying("A002CE0F", "2A000000"), Params("Udt", AbCipDataType.DInt)); + rt.GetStatus().ShouldBe((int)AbStatus.Ok); + } + + [Fact] + public async Task Unknown_tag_fails_initialize_with_NotFound() + { + var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("D2000400")) }; + using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Nope", AbCipDataType.DInt)); + (await Should.ThrowAsync(() => rt.InitializeAsync(CancellationToken.None))) + .Status.ShouldBe(AbStatus.NotFound); + rt.GetStatus().ShouldBe((int)AbStatus.NotFound); + } + + [Fact] + public async Task A_one_element_array_is_read_as_an_array() + { + var ch = Replying("C400", "2A000000"); + using var rt = await ReadAsync(ch, Params("Arr", AbCipDataType.DInt, elementCount: 1, isArray: true)); + rt.DecodeArray(AbCipDataType.DInt, 1).ShouldBe(new[] { 42 }); + } + + [Fact] + public async Task An_array_read_requests_every_element_and_decodes_at_the_buffer_stride() + { + var ch = Replying("C400", "01000000" + "02000000" + "03000000"); + using var rt = await ReadAsync(ch, Params("Arr", AbCipDataType.DInt, elementCount: 3, isArray: true)); + rt.DecodeArray(AbCipDataType.DInt, 3).ShouldBe(new[] { 1, 2, 3 }); + ch.Sent[0].ShouldBe(Convert.FromHexString("5203" + "9103417272" + "00" + "0300" + "00000000")); + } + + [Fact] + public async Task A_BOOL_array_is_bit_packed() + { + using var rt = await ReadAsync(Replying("D300", "05000000"), Params("Bits", AbCipDataType.Bool, 3, true)); + rt.DecodeArray(AbCipDataType.Bool, 3).ShouldBe(new[] { true, false, true }); + } + + [Fact] + public async Task A_user_string_array_uses_the_buffer_derived_stride() + { + // Two STR0016-style elements of 20 bytes (LEN + 16 DATA), not the static 88. + static string Element(string s) => + Convert.ToHexString(LittleEndianU32(s.Length)) + Convert.ToHexString(Encoding.ASCII.GetBytes(s)).PadRight(32, '0'); + using var rt = await ReadAsync( + Replying("A002AAAA", Element("ab") + Element("xyz")), Params("Strs", AbCipDataType.String, 2, true)); + rt.DecodeArray(AbCipDataType.String, 2).ShouldBe(new[] { "ab", "xyz" }); + } + + private static byte[] LittleEndianU32(int v) => [(byte)v, (byte)(v >> 8), (byte)(v >> 16), (byte)(v >> 24)]; + + [Fact] + public async Task Decoding_past_the_buffer_is_OutOfBounds_as_libplctag_reports_it() + { + using var rt = await ReadAsync(Replying("C400", "2A000000"), Params("T", AbCipDataType.DInt)); + Should.Throw(() => rt.DecodeValueAt(AbCipDataType.DInt, 2, null)) + .Status.ShouldBe(AbStatus.OutOfBounds); + } + + [Fact] + public async Task A_DINT_write_sends_Write_Tag_with_the_read_type() + { + var ch = Replying("C400", "00000000"); + using var rt = await ReadAsync(ch, Params("T", AbCipDataType.DInt)); + ch.Respond = req => Task.FromResult(FakeCipChannel.OkReply(req)); + rt.EncodeValue(AbCipDataType.DInt, null, 42); + await rt.WriteAsync(CancellationToken.None); + rt.GetStatus().ShouldBe((int)AbStatus.Ok); + ch.Sent[^1].ShouldBe(Convert.FromHexString("4D02" + "91015400" + "C400" + "0100" + "2A000000")); + } + + [Fact] + public async Task A_STRING_write_sends_Struct_and_the_handle_learned_from_the_prior_read() + { + var ch = Replying("A002CE0F", "00000000" + new string('0', 2 * 84)); + using var rt = await ReadAsync(ch, Params("S", AbCipDataType.String)); + ch.Respond = req => Task.FromResult(FakeCipChannel.OkReply(req)); + rt.EncodeValue(AbCipDataType.String, null, "Hi"); + await rt.WriteAsync(CancellationToken.None); + + var expected = "4D02" + "91015300" + "A002" + "CE0F" + "0100" + + "02000000" + "4869" + new string('0', 2 * 82); + ch.Sent[^1].ShouldBe(Convert.FromHexString(expected)); + } + + [Fact] + public async Task A_STRING_write_matches_the_libplctag_capture_when_the_server_reports_type_D0() + { + // Captures/logix-write-string.hex: ab_server reports a STRING as type 0x00D0 with no handle, and + // libplctag writes it back as D0 00, element count 1, LEN + DATA padded to the 88-byte read size. + var ch = Replying("D000", new string('0', 2 * 88)); + using var rt = await ReadAsync(ch, Params("TestString", AbCipDataType.String)); + ch.Respond = req => Task.FromResult(FakeCipChannel.OkReply(req)); + rt.EncodeValue(AbCipDataType.String, null, "HelloAB"); + await rt.WriteAsync(CancellationToken.None); + + var captured = "4D06910A54657374537472696E67D00001000700000048656C6C6F4142" + new string('0', 2 * 77); + ch.Sent[^1].ShouldBe(Convert.FromHexString(captured)); + } + + [Fact] + public async Task A_STRING_longer_than_the_learned_capacity_is_TooLarge() + { + var ch = Replying("A002CE0F", "00000000" + "00000000"); + using var rt = await ReadAsync(ch, Params("S", AbCipDataType.String)); + rt.EncodeValue(AbCipDataType.String, null, "toolong"); + (await Should.ThrowAsync(() => rt.WriteAsync(CancellationToken.None))) + .Status.ShouldBe(AbStatus.TooLarge); + rt.GetStatus().ShouldBe((int)AbStatus.TooLarge); + } + + [Fact] + public async Task A_write_whose_width_disagrees_with_the_controller_type_is_TypeMismatch_and_sends_nothing() + { + var ch = Replying("C400", "00000000"); + using var rt = await ReadAsync(ch, Params("T", null)); + var sentBefore = ch.SentCount; + rt.EncodeValue(AbCipDataType.Int, null, 1); + (await Should.ThrowAsync(() => rt.WriteAsync(CancellationToken.None))) + .Status.ShouldBe(AbStatus.TypeMismatch); + ch.SentCount.ShouldBe(sentBefore); + } + + [Fact] + public async Task A_Dt_write_under_DATE_type_0xCD_sends_four_bytes() + { + // 0xCD is outside LogixTypeCode's width table: no width check, the write goes out as today. + var ch = Replying("CD00", "00000000"); + using var rt = await ReadAsync(ch, Params("D", AbCipDataType.Dt)); + rt.GetStatus().ShouldBe((int)AbStatus.Ok); + ch.Respond = req => Task.FromResult(FakeCipChannel.OkReply(req)); + rt.EncodeValue(AbCipDataType.Dt, null, 123456); + await rt.WriteAsync(CancellationToken.None); + ch.Sent[^1].ShouldBe(Convert.FromHexString("4D02" + "91014400" + "CD00" + "0100" + "40E20100")); + } + + [Fact] + public async Task A_BOOL_write_over_a_packed_BOOL_array_DWORD_is_NotSupported_and_sends_nothing() + { + var ch = Replying("D300", "01000000"); + using var rt = await ReadAsync(ch, Params("Bits[0]", AbCipDataType.Bool)); + var sentBefore = ch.SentCount; + rt.EncodeValue(AbCipDataType.Bool, null, false); + await Should.ThrowAsync(() => rt.WriteAsync(CancellationToken.None)); + ch.SentCount.ShouldBe(sentBefore); + } + + [Theory] + [InlineData(AbCipDataType.DInt)] + [InlineData(AbCipDataType.Int)] + [InlineData(AbCipDataType.Bool)] + public async Task Any_write_to_a_bit_tag_is_NotSupported_and_sends_nothing(AbCipDataType authored) + { + // "Flags.3" authored as DInt/Int would otherwise write the value over the WHOLE host DINT. + var ch = Replying("C400", "08000000"); + using var rt = await ReadAsync(ch, Params("Flags.3", authored)); + var sentBefore = ch.SentCount; + rt.EncodeValue(authored, null, 1); + await Should.ThrowAsync(() => rt.WriteAsync(CancellationToken.None)); + ch.SentCount.ShouldBe(sentBefore); + } + + [Fact] + public async Task A_write_after_a_same_width_type_mismatch_is_TypeMismatch_and_sends_nothing() + { + // REAL returned for a DINT-authored tag: same width, so only the read rule can catch it. + var ch = Replying("CA00", "0000803F"); + using var rt = await ReadAsync(ch, Params("Speed", AbCipDataType.DInt)); + var sentBefore = ch.SentCount; + rt.EncodeValue(AbCipDataType.DInt, null, 42); + (await Should.ThrowAsync(() => rt.WriteAsync(CancellationToken.None))) + .Status.ShouldBe(AbStatus.TypeMismatch); + ch.SentCount.ShouldBe(sentBefore); + rt.GetStatus().ShouldBe((int)AbStatus.TypeMismatch); + } + + [Theory] + [InlineData(AbCipDataType.DInt, "C800", "2A000000", 42)] // DINT ← UDINT + [InlineData(AbCipDataType.UDInt, "C400", "2A000000", 42u)] // UDINT ← DINT + [InlineData(AbCipDataType.Int, "C700", "2A00", 42)] // INT ← UINT + [InlineData(AbCipDataType.UInt, "C300", "2A00", 42)] // UINT ← INT + [InlineData(AbCipDataType.SInt, "C600", "2A", 42)] // SINT ← USINT + [InlineData(AbCipDataType.USInt, "C200", "2A", 42)] // USINT ← SINT + [InlineData(AbCipDataType.LInt, "C900", "2A00000000000000", 42L)] // LINT ← ULINT + [InlineData(AbCipDataType.ULInt, "C500", "2A00000000000000", 42UL)] // ULINT ← LINT + [InlineData(AbCipDataType.DInt, "D300", "2A000000", 42)] // DINT ← Micro800 DWORD + [InlineData(AbCipDataType.UDInt, "D300", "2A000000", 42u)] // UDINT ← Micro800 DWORD + [InlineData(AbCipDataType.Int, "D200", "2A00", 42)] // INT ← Micro800 WORD + [InlineData(AbCipDataType.USInt, "D100", "2A", 42)] // USINT ← Micro800 BYTE + [InlineData(AbCipDataType.LInt, "D400", "2A00000000000000", 42L)] // LINT ← Micro800 LWORD + public async Task Same_width_integer_pairs_are_not_a_type_mismatch( + AbCipDataType authored, string typeHex, string dataHex, object expected) + { + using var rt = await ReadAsync(Replying(typeHex, dataHex), Params("T", authored)); + rt.GetStatus().ShouldBe((int)AbStatus.Ok); + rt.DecodeValue(authored, null).ShouldBe(expected); + } + + [Theory] + [InlineData(AbCipDataType.Int, "C400", "2A000000")] // different width + [InlineData(AbCipDataType.Real, "D300", "2A000000")] // integer ↔ real, same width + [InlineData(AbCipDataType.UDInt, "CA00", "0000803F")] // real ↔ integer, same width + public async Task Different_widths_and_integer_real_pairs_still_mismatch( + AbCipDataType authored, string typeHex, string dataHex) + { + using var rt = await ReadAsync(Replying(typeHex, dataHex), Params("T", authored)); + rt.GetStatus().ShouldBe((int)AbStatus.TypeMismatch); + } + + [Fact] + public async Task A_malformed_tag_name_never_throws_from_Create_and_surfaces_on_Initialize_without_IO() + { + var ch = new FakeCipChannel(); + var p = Params("Arr[abc]", AbCipDataType.DInt); + using var created = new NativeAbCipTagFactory().Create(p); // must not throw + using var rt = NativeAbCipTagRuntime.ForTest(ch, p); + await Should.ThrowAsync(() => rt.InitializeAsync(CancellationToken.None)); + ch.SentCount.ShouldBe(0); + } + + [Fact] + public async Task A_refused_write_stores_the_status_and_rethrows() + { + var ch = Replying("C400", "00000000"); + using var rt = await ReadAsync(ch, Params("T", AbCipDataType.DInt)); + ch.Respond = req => Task.FromResult(FakeCipChannel.Reply(req[0], 0x0F, [])); + rt.EncodeValue(AbCipDataType.DInt, null, 1); + (await Should.ThrowAsync(() => rt.WriteAsync(CancellationToken.None))) + .Status.ShouldBe(AbStatus.NotAllowed); + rt.GetStatus().ShouldBe((int)AbStatus.NotAllowed); + } + + [Fact] + public void Encode_refuses_the_same_cases_as_libplctag() + { + using var rt = NativeAbCipTagRuntime.ForTest(new FakeCipChannel(), Params("T", AbCipDataType.Bool)); + Should.Throw(() => rt.EncodeValue(AbCipDataType.Bool, 3, true)); + Should.Throw(() => rt.EncodeValue(AbCipDataType.Structure, null, 1)); + Should.Throw(() => rt.EncodeValue((AbCipDataType)999, null, 1)); + } + + [Fact] + public void The_device_key_defaults_the_port_and_keeps_path_and_family() + { + NativeAbCipDevice.Key(Params("T", null) with { Port = 0 }) + .ShouldBe(new DeviceKey("10.0.0.5", 44818, "1,0", "controllogix")); + NativeAbCipDevice.Key(Params("T", null) with { Port = 2222 }).Port.ShouldBe(2222); + } + + [Fact] + public void Connection_options_carry_timeout_size_and_packing() + { + var o = NativeAbCipDevice.Options(Params("T", null) with { ConnectionSize = 504, AllowPacking = true }); + o.Timeout.ShouldBe(TimeSpan.FromSeconds(2)); + o.ConnectionSize.ShouldBe(504); + o.AllowPacking.ShouldBeTrue(); + } + + // Routing cross-check against Captures/README.md "Routing observed" (libplctag 2.6.0 on the wire): + // controllogix (path=1,0) → Large Forward Open, requests connected ⇒ Connected — matches. + // micro800 (no path) → Large Forward Open, path 20 02 24 01 ⇒ Connected — matches, + // even though the Micro800 profile says SupportsConnectedMessaging=false (ruling R12-1: the + // capture micro800-read-dint.hex beats the profile flag; the brief's UseConnectedMessaging rule + // alone would have chosen Direct, which is a MISMATCH with the capture). + // The UnconnectedSend / Direct rows below have no Logix-family capture; they follow the brief's rule. + [Theory] + [InlineData("controllogix", "1,0", true, CipRouting.Connected)] + [InlineData("compactlogix", "1,0", true, CipRouting.Connected)] + [InlineData("controllogix", "1,0", false, CipRouting.UnconnectedSend)] + [InlineData("controllogix", "", false, CipRouting.Direct)] + [InlineData("micro800", "", false, CipRouting.Connected)] + [InlineData("micro800", "", true, CipRouting.Connected)] + public void Routing_follows_the_family_profile_with_Micro800_always_connected( + string plc, string cipPath, bool connected, CipRouting expected) => + NativeAbCipDevice.Options(Params("T", null, plc: plc, cipPath: cipPath, connected: connected)) + .Routing.ShouldBe(expected); + + [Fact] + public void The_factory_creates_native_runtimes() + { + using var rt = new NativeAbCipTagFactory().Create(Params("T", AbCipDataType.DInt)); + rt.ShouldBeOfType(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTemplateReaderTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTemplateReaderTests.cs new file mode 100644 index 00000000..fe0cbaa4 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTemplateReaderTests.cs @@ -0,0 +1,197 @@ +using System.Buffers.Binary; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// Task 13: the native template reader over , serving the real +/// controller captures (rev 34.11) as Read Template reply data. The native buffer must decode +/// identically to the captured libplctag buffer. +/// +[Trait("Category", "Unit")] +public sealed class NativeAbCipTemplateReaderTests +{ + private static readonly AbCipTagCreateParams Params = new( + Gateway: "10.0.0.5", Port: 44818, CipPath: "1,0", LibplctagPlcAttribute: "controllogix", + TagName: "@udt", Timeout: TimeSpan.FromSeconds(2)); + + /// Builds a channel serving (header + raw) the way a controller would. + private static FakeCipChannel ServeTemplate(byte[] captured, int maxRequestBytes, int? statusOverrideAtFirst = null) + { + var ch = new FakeCipChannel(maxRequestBytes); + ch.RespondPerService = ServeTemplatePerService(captured, maxRequestBytes); + return ch; + } + + private static Func ServeTemplatePerService(byte[] captured, int maxRequestBytes) + { + var id = BinaryPrimitives.ReadUInt16LittleEndian(captured); + var defWords = BinaryPrimitives.ReadUInt32LittleEndian(captured.AsSpan(2)); + var instBytes = BinaryPrimitives.ReadUInt32LittleEndian(captured.AsSpan(6)); + var members = BinaryPrimitives.ReadUInt16LittleEndian(captured.AsSpan(10)); + var handle = BinaryPrimitives.ReadUInt16LittleEndian(captured.AsSpan(12)); + var raw = captured.AsSpan(14).ToArray(); + var total = (int)(defWords * 4 - 23); + return req => + { + if (req[0] == 0x03) + { + var d = new byte[30]; + var w = d.AsSpan(); + BinaryPrimitives.WriteUInt16LittleEndian(w, 4); + BinaryPrimitives.WriteUInt16LittleEndian(w[2..], 4); BinaryPrimitives.WriteUInt16LittleEndian(w[4..], 0); + BinaryPrimitives.WriteUInt32LittleEndian(w[6..], defWords); + BinaryPrimitives.WriteUInt16LittleEndian(w[10..], 5); BinaryPrimitives.WriteUInt16LittleEndian(w[12..], 0); + BinaryPrimitives.WriteUInt32LittleEndian(w[14..], instBytes); + BinaryPrimitives.WriteUInt16LittleEndian(w[18..], 2); BinaryPrimitives.WriteUInt16LittleEndian(w[20..], 0); + BinaryPrimitives.WriteUInt16LittleEndian(w[22..], members); + BinaryPrimitives.WriteUInt16LittleEndian(w[24..], 1); BinaryPrimitives.WriteUInt16LittleEndian(w[26..], 0); + BinaryPrimitives.WriteUInt16LittleEndian(w[28..], handle); + return FakeCipChannel.Reply(0x03, 0, d); + } + req[0].ShouldBe((byte)0x4C); + var offset = (int)BinaryPrimitives.ReadUInt32LittleEndian(req.AsSpan(8)); + var count = BinaryPrimitives.ReadUInt16LittleEndian(req.AsSpan(12)); + count.ShouldBeLessThanOrEqualTo((ushort)(maxRequestBytes - 16)); + var n = Math.Min(count, total - offset); + var done = offset + n >= total; + return FakeCipChannel.Reply(0x4C, done ? (byte)0x00 : (byte)0x06, raw[offset..(offset + n)]); + }; + } + + private static async Task ReadNativeAsync(FakeCipChannel ch, ushort id) + { + await using var sched = new RequestScheduler(ch, allowPacking: false); + var released = 0; + using var reader = new NativeAbCipTemplateReader(_ => + (sched, ch, () => { released++; return ValueTask.CompletedTask; })); + var buf = await reader.ReadAsync(Params, id, TestContext.Current.CancellationToken); + released.ShouldBe(1); + return buf; + } + + [Theory] + [InlineData(CipTemplateObjectWireCaptureTests.StringTemplate, 504)] + [InlineData(CipTemplateObjectWireCaptureTests.StringTemplate, 40)] + [InlineData(CipTemplateObjectWireCaptureTests.LookupMemberTemplate, 504)] + [InlineData(CipTemplateObjectWireCaptureTests.LookupMemberTemplate, 40)] + public async Task Native_buffer_decodes_identically_to_the_captured_libplctag_buffer(string hex, int maxRequest) + { + var captured = Convert.FromHexString(hex); + var ch = ServeTemplate(captured, maxRequest); + var id = BinaryPrimitives.ReadUInt16LittleEndian(captured); + + var native = await ReadNativeAsync(ch, id); + + var expected = CipTemplateObjectDecoder.Decode(captured); + var actual = CipTemplateObjectDecoder.Decode(native); + expected.ShouldNotBeNull(); + actual.ShouldNotBeNull(); + actual.TypeName.ShouldBe(expected.TypeName); + actual.TotalSize.ShouldBe(expected.TotalSize); + actual.Members.Count.ShouldBe(expected.Members.Count); + for (var i = 0; i < expected.Members.Count; i++) + { + actual.Members[i].Name.ShouldBe(expected.Members[i].Name); + actual.Members[i].DataType.ShouldBe(expected.Members[i].DataType); + actual.Members[i].Offset.ShouldBe(expected.Members[i].Offset); + actual.Members[i].ArrayLength.ShouldBe(expected.Members[i].ArrayLength); + } + // The 14-byte header is byte for byte the libplctag one. + native.AsSpan(0, 14).SequenceEqual(captured.AsSpan(0, 14)).ShouldBeTrue(); + } + + [Fact] + public async Task A_small_request_limit_forces_several_chunks_each_within_the_limit() + { + var captured = Convert.FromHexString(CipTemplateObjectWireCaptureTests.LookupMemberTemplate); + var ch = ServeTemplate(captured, 40); + + await ReadNativeAsync(ch, 3386); + + ch.Sent.Count(r => r[0] == 0x4C).ShouldBeGreaterThan(2); + } + + [Fact] + public async Task A_failing_read_template_status_throws_and_still_releases_the_lease() + { + var captured = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate); + var ch = ServeTemplate(captured, 504); + var inner = ch.RespondPerService; + ch.RespondPerService = req => req[0] == 0x4C ? FakeCipChannel.Reply(0x4C, 0x05, []) : inner(req); + await using var sched = new RequestScheduler(ch, allowPacking: false); + var released = 0; + using var reader = new NativeAbCipTemplateReader(_ => + (sched, ch, () => { released++; return ValueTask.CompletedTask; })); + + await Should.ThrowAsync(() => + reader.ReadAsync(Params, 0x0FCE, TestContext.Current.CancellationToken)); + released.ShouldBe(1); + } + + [Fact] + public async Task A_read_template_queued_behind_a_small_read_on_a_packing_scheduler_goes_out_alone() + { + var captured = Convert.FromHexString(CipTemplateObjectWireCaptureTests.LookupMemberTemplate); + var ch = ServeTemplate(captured, 504); + await using var sched = new RequestScheduler(ch, allowPacking: true); + var ct = TestContext.Current.CancellationToken; + var small = new byte[] { 0x0E, 0x02, 0x20, 0x01, 0x24, 0x01 }; // Get Attribute Single + var hold = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var inner = ch.RespondPerService; + var firstSmall = true; + Task? s1 = null; + ch.Respond = async req => + { + // The Get Attributes reply is delivered with a small read already queued behind it, so the + // pump takes that read next (S1) and holds it in flight while the reader submits its first + // Read Template: that template is genuinely queued behind another request. + if (req[0] == 0x03) + { + s1 = sched.SendAsync(small, ct); + return await ch.DefaultRespond(req); + } + if (req[0] == 0x0E && firstSmall) + { + firstSmall = false; + await hold.Task; + } + return await ch.DefaultRespond(req); + }; + ch.RespondPerService = r => r[0] == 0x0E ? FakeCipChannel.OkReply(r) : inner(r); + using var reader = new NativeAbCipTemplateReader(_ => (sched, ch, () => ValueTask.CompletedTask)); + + var read = reader.ReadAsync(Params, 3386, ct); + await ch.WaitForSendsAsync(2); // Get Attributes, then S1 held in flight + var s2 = sched.SendAsync(small, ct); + using var bound = CancellationTokenSource.CreateLinkedTokenSource(ct); + bound.CancelAfter(TimeSpan.FromSeconds(10)); + while (sched.QueuedCount < 2) await Task.Delay(1, bound.Token); // S2 + the first Read Template + hold.SetResult(); + await read; + await Task.WhenAll(s1!, s2); + + ch.Sent.Where(r => r[0] == 0x0A).ShouldAllBe(msp => FakeCipChannel.SplitMsp(msp).All(e => e[0] != 0x4C)); + ch.Sent.ShouldContain(r => r[0] == 0x4C); // the Read Template requests went out bare + } + + [Fact] + public async Task A_template_size_over_the_bound_is_rejected() + { + var captured = Convert.FromHexString(CipTemplateObjectWireCaptureTests.StringTemplate); + BinaryPrimitives.WriteUInt32LittleEndian(captured.AsSpan(2), 0x10000000); + var ch = ServeTemplate(captured, 504); + await using var sched = new RequestScheduler(ch, allowPacking: false); + using var reader = new NativeAbCipTemplateReader(_ => (sched, ch, () => ValueTask.CompletedTask)); + + var ex = await Should.ThrowAsync( + () => reader.ReadAsync(Params, 0x0FCE, TestContext.Current.CancellationToken)); + + ex.Status.ShouldBe(AbStatus.BadReply); + ch.Sent.ShouldNotContain(r => r[0] == 0x4C); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj index b35b6c83..38c3d151 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj @@ -28,6 +28,14 @@ + + + + + + + + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs index 9bbfd8f4..16592fa8 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs @@ -8,7 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; /// /// End-to-end smoke tests against the ab_server PCCC Docker container. /// Promotes the AB Legacy driver from unit-only coverage (FakeAbLegacyTag) -/// to wire-level: real libplctag PCCC stack over real TCP against the ab_server +/// to wire-level: real PCCC stack (libplctag and native, via ) over real TCP against the ab_server /// simulator. Parametrised over all three families (SLC 500 / MicroLogix / PLC-5) /// via [AbLegacyTheory] + [MemberData]. /// @@ -17,29 +17,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; [Trait("Simulator", "ab_server-PCCC")] public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim) { - // Only one ab_server container binds :44818 at a time and `--plc=SLC500` only - // answers SLC-mode PCCC, etc. When `AB_LEGACY_COMPOSE_PROFILE` is set, the theory - // filters to that profile alone so the suite matches the running container. Unset - // (the default for real-hardware runs) parameterises across every family the driver - // supports. - /// Gets the available server profiles based on compose configuration. - public static IEnumerable Profiles - { - get - { - var only = Environment.GetEnvironmentVariable("AB_LEGACY_COMPOSE_PROFILE"); - var profiles = KnownProfiles.All.Where(p => - string.IsNullOrEmpty(only) || - string.Equals(p.ComposeProfile, only, StringComparison.OrdinalIgnoreCase)); - return profiles.Select(p => new object[] { p }); - } - } + // Rows come from TransportMatrix.Profiles, which keeps the AB_LEGACY_COMPOSE_PROFILE filter this + // class applied before the transport split: when set, only that profile is crossed with the + // transports, so the suite matches the running container. /// Verifies that the driver reads seeded N file from the AB server via PCCC. /// The AB Legacy server profile describing the fixture endpoint. + /// The transport stack the driver runs on. [AbLegacyTheory] - [MemberData(nameof(Profiles))] - public async Task Driver_reads_seeded_N_file_from_ab_server_PCCC(AbLegacyServerProfile profile) + [MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))] + public async Task Driver_reads_seeded_N_file_from_ab_server_PCCC(AbLegacyServerProfile profile, AbLegacyTransport transport) { if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip); @@ -59,7 +46,8 @@ public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim) DataType: AbLegacyDataType.Int)), Timeout = TimeSpan.FromSeconds(5), Probe = new AbLegacyProbeOptions { Enabled = false }, - }, driverInstanceId: $"ablegacy-smoke-{profile.Family}"); + Transport = transport, + }, driverInstanceId: $"ablegacy-smoke-{profile.Family}-{transport}"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); @@ -72,8 +60,10 @@ public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim) } /// Verifies that SLC500 write-then-read round trip succeeds on N7 scratch register. - [AbLegacyFact] - public async Task Slc500_write_then_read_round_trip_on_N7_scratch_register() + /// The transport stack the driver runs on. + [AbLegacyTheory] + [MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))] + public async Task Slc500_write_then_read_round_trip_on_N7_scratch_register(AbLegacyTransport transport) { // Addresses the SLC500 container by its OWN port, so it no longer matters which other // families happen to be running. The old AB_LEGACY_COMPOSE_PROFILE guard existed because every @@ -81,6 +71,7 @@ public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim) // container held the port; it survives below only for real-hardware runs, where // AB_LEGACY_ENDPOINT pins one device and the family IS a property of that device. if (sim.SkipReasonFor(KnownProfiles.Slc500) is { } skip) Assert.Skip(skip); + IntegrationWriteGate.SkipUnlessWritesAllowed(); // writes N7:5 var only = Environment.GetEnvironmentVariable("AB_LEGACY_COMPOSE_PROFILE"); if (sim.EndpointPinned && !string.IsNullOrEmpty(only) && @@ -106,7 +97,8 @@ public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim) Writable: true)), Timeout = TimeSpan.FromSeconds(5), Probe = new AbLegacyProbeOptions { Enabled = false }, - }, driverInstanceId: "ablegacy-smoke-rw"); + Transport = transport, + }, driverInstanceId: $"ablegacy-smoke-rw-{transport}"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md index 84edcf90..3aec0909 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/README.md @@ -53,6 +53,7 @@ families stop the current service + start another. |---|---|---| | `AB_LEGACY_ENDPOINT` | *unset* — per-family ports on `10.100.0.35` | `host:port` of a single PCCC device, pinning **every** family to it (real-hardware mode). | | `AB_LEGACY_CIP_PATH` | `1,0` | CIP routing path portion of the `ab://host:port/` URI. ab_server rejects empty paths at the CIP unconnected-send layer; real SLC/MicroLogix/PLC-5 hardware accepts empty (no backplane). Set to empty (`AB_LEGACY_CIP_PATH=`) when pointing at real hardware. | +| `AB_SERVER_ALLOW_WRITES` | *unset* | **Write opt-in.** Every write-capable test (today: the SLC500 N7:5 write-then-read round trip) skips unless this is `1`. Set it for simulator runs; **never** set it when an endpoint variable points at production hardware. Write tests also refuse while `AB_DIFF_ENDPOINT` (the AbCip read-only differential) is set. | | `AB_LEGACY_COMPOSE_PROFILE` | *unset* | Only consulted when `AB_LEGACY_ENDPOINT` pins an endpoint: names which family that one device is, so the SLC500-only write test skips against other hardware. Against the Docker fixture it is unnecessary — each family has its own port, and each test skips itself if its own port is down. | ## Run the integration tests @@ -63,6 +64,7 @@ running so only the matching theory-parameterization executes: ```powershell cd C:\Users\dohertj2\Desktop\lmxopcua $env:AB_LEGACY_COMPOSE_PROFILE = "slc500" # or "micrologix" / "plc5" +$env:AB_SERVER_ALLOW_WRITES = "1" # simulator only: lets the write round-trip test run dotnet test tests\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests ``` diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/IntegrationWriteGate.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/IntegrationWriteGate.cs new file mode 100644 index 00000000..4a183ca8 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/IntegrationWriteGate.cs @@ -0,0 +1,26 @@ +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; + +/// +/// Opt-in gate for every integration test that WRITES to the endpoint under test. A write test runs only +/// when AB_SERVER_ALLOW_WRITES=1, in addition to its own reachability / profile conditions, so +/// pointing an endpoint variable at a real controller (a production press in Task 21) can never write to +/// it by accident. It also refuses while AB_DIFF_ENDPOINT is set, because that variable means a +/// read-only differential run is in progress. The ab_server / simulator runs set the opt-in explicitly. +/// +public static class IntegrationWriteGate +{ + public const string AllowWritesVar = "AB_SERVER_ALLOW_WRITES"; + public const string DifferentialEndpointVar = "AB_DIFF_ENDPOINT"; + + /// Skips the calling test unless writes are explicitly allowed for this run. + public static void SkipUnlessWritesAllowed() + { + if (Environment.GetEnvironmentVariable(DifferentialEndpointVar) is { Length: > 0 }) + Assert.Skip($"Write test refused: {DifferentialEndpointVar} is set, so a read-only differential run is in progress."); + if (Environment.GetEnvironmentVariable(AllowWritesVar) != "1") + Assert.Skip($"Write test: set {AllowWritesVar}=1 to allow writes to the endpoint under test " + + "(simulator runs only — never a production controller)."); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs new file mode 100644 index 00000000..fbfdc7d1 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs @@ -0,0 +1,35 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; + +/// +/// Crosses the PCCC compose profiles with both AbLegacy transports so each integration test runs +/// once on libplctag and once on the native managed stack. +/// +/// +/// Preserves the AB_LEGACY_COMPOSE_PROFILE filter the smoke theory always applied: when set, +/// is narrowed to that profile before it is crossed with the +/// transports, so the rows match the running container. +/// +public static class TransportMatrix +{ + /// Both transports, for tests that are not parameterised by profile. + public static IReadOnlyList AllTransports { get; } = + [AbLegacyTransport.Libplctag, AbLegacyTransport.Native]; + + /// Gets (profile, transport) rows for every selected profile × both transports. + public static IEnumerable Profiles + { + get + { + var only = Environment.GetEnvironmentVariable("AB_LEGACY_COMPOSE_PROFILE"); + return from p in KnownProfiles.All + where string.IsNullOrEmpty(only) || + string.Equals(p.ComposeProfile, only, StringComparison.OrdinalIgnoreCase) + from t in AllTransports + select new object[] { p, t }; + } + } + + /// Gets one row per transport. + public static IEnumerable Transports => + AllTransports.Select(t => new object[] { t }); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayTests.cs index b01bfbee..9c5e428d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayTests.cs @@ -112,7 +112,7 @@ public sealed class AbLegacyArrayTests var (drv, factory) = NewDriver( new AbLegacyTagDefinition("Levels", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int, ArrayLength: 5)); await drv.InitializeAsync("{}", CancellationToken.None); - factory.Customise = p => new FakeAbLegacyTag(p) { Status = (int)libplctag.Status.ErrorTimeout }; + factory.Customise = p => new FakeAbLegacyTag(p) { Status = (int)ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbStatus.Timeout }; var snapshots = await drv.ReadAsync(["Levels"], CancellationToken.None); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBitRmwTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBitRmwTests.cs index 53824c40..3229fb53 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBitRmwTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBitRmwTests.cs @@ -229,7 +229,7 @@ public sealed class AbLegacyBitRmwTests public async Task Bit_write_surfaces_device_rejection_status() { // Arrange: the parent tag reads OK (Status = 0) but write returns a timeout error. - const int errorTimeout = (int)libplctag.Status.ErrorTimeout; + const int errorTimeout = (int)ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbStatus.Timeout; var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) @@ -252,6 +252,6 @@ public sealed class AbLegacyBitRmwTests // Assert: the write rejection must NOT be silently swallowed as Good. var statusCode = results.Single().StatusCode; statusCode.ShouldNotBe(AbLegacyStatusMapper.Good); - statusCode.ShouldBe(AbLegacyStatusMapper.MapLibplctagStatus(errorTimeout)); + statusCode.ShouldBe(AbLegacyStatusMapper.MapStatus(errorTimeout)); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyEvictOnFailureTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyEvictOnFailureTests.cs index 428c1144..aebeb1a1 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyEvictOnFailureTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyEvictOnFailureTests.cs @@ -1,4 +1,4 @@ -using libplctag; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -42,7 +42,7 @@ public sealed class AbLegacyEvictOnFailureTests { callCount++; return callCount == 1 - ? new FakeAbLegacyTag(p) { Status = (int)Status.ErrorBadConnection } + ? new FakeAbLegacyTag(p) { Status = (int)AbStatus.BadConnection } : new FakeAbLegacyTag(p) { Status = 0, Value = 42 }; }; await drv.InitializeAsync("{}", CancellationToken.None); @@ -94,7 +94,7 @@ public sealed class AbLegacyEvictOnFailureTests { callCount++; return callCount == 1 - ? new FakeAbLegacyTag(p) { Status = (int)Status.ErrorBadConnection } + ? new FakeAbLegacyTag(p) { Status = (int)AbStatus.BadConnection } : new FakeAbLegacyTag(p) { Status = 0 }; }; await drv.InitializeAsync("{}", CancellationToken.None); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs index 1af52555..07c5abdb 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs @@ -1,6 +1,7 @@ using libplctag; using Shouldly; using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; @@ -65,42 +66,75 @@ public sealed class AbLegacyHostAndStatusTests AbLegacyStatusMapper.MapPcccStatus(sts).ShouldBe(expected); } - /// Verifies that libplctag Status enum members are mapped to OPC UA status codes correctly. - /// The libplctag Status enum value to map. + /// Verifies that AbStatus members map to OPC UA status codes correctly. + /// The AbStatus value to map. /// The expected OPC UA status code. - // Driver.AbLegacy-010 — tests use the libplctag.NET Status enum members (what - // (int)Tag.GetStatus() actually returns) rather than the unverified magic integers - // that predated this fix (-5/-7/-14/-16/-17 matched neither native PLCTAG_ERR_* - // constants nor the .NET wrapper enum ordinals reliably). [Theory] - [InlineData(Status.Ok, AbLegacyStatusMapper.Good)] - [InlineData(Status.Pending, AbLegacyStatusMapper.GoodMoreData)] - [InlineData(Status.ErrorTimeout, AbLegacyStatusMapper.BadTimeout)] - [InlineData(Status.ErrorNotFound, AbLegacyStatusMapper.BadNodeIdUnknown)] - [InlineData(Status.ErrorNoMatch, AbLegacyStatusMapper.BadNodeIdUnknown)] - [InlineData(Status.ErrorNotAllowed, AbLegacyStatusMapper.BadNotWritable)] - [InlineData(Status.ErrorOutOfBounds, AbLegacyStatusMapper.BadOutOfRange)] - [InlineData(Status.ErrorTooLarge, AbLegacyStatusMapper.BadOutOfRange)] - [InlineData(Status.ErrorBadConnection, AbLegacyStatusMapper.BadCommunicationError)] - [InlineData(Status.ErrorBadGateway, AbLegacyStatusMapper.BadCommunicationError)] - [InlineData(Status.ErrorUnsupported, AbLegacyStatusMapper.BadNotSupported)] - [InlineData(Status.ErrorNoMem, AbLegacyStatusMapper.BadCommunicationError)] // unmapped → generic comms - public void LibplctagStatus_maps_real_enum_members(Status status, uint expected) + [InlineData(AbStatus.Ok, AbLegacyStatusMapper.Good)] + [InlineData(AbStatus.Pending, AbLegacyStatusMapper.GoodMoreData)] + [InlineData(AbStatus.Timeout, AbLegacyStatusMapper.BadTimeout)] + [InlineData(AbStatus.NotFound, AbLegacyStatusMapper.BadNodeIdUnknown)] + [InlineData(AbStatus.NotAllowed, AbLegacyStatusMapper.BadNotWritable)] + [InlineData(AbStatus.OutOfBounds, AbLegacyStatusMapper.BadOutOfRange)] + [InlineData(AbStatus.TooLarge, AbLegacyStatusMapper.BadOutOfRange)] + [InlineData(AbStatus.TooSmall, AbLegacyStatusMapper.BadOutOfRange)] + [InlineData(AbStatus.Unsupported, AbLegacyStatusMapper.BadNotSupported)] + [InlineData(AbStatus.TypeMismatch, AbLegacyStatusMapper.BadTypeMismatch)] + [InlineData(AbStatus.DeviceStateConflict, AbLegacyStatusMapper.BadDeviceFailure)] + [InlineData(AbStatus.BadConnection, AbLegacyStatusMapper.BadCommunicationError)] + [InlineData(AbStatus.BadReply, AbLegacyStatusMapper.BadCommunicationError)] + [InlineData(AbStatus.Partial, AbLegacyStatusMapper.BadCommunicationError)] + [InlineData(AbStatus.Abort, AbLegacyStatusMapper.BadCommunicationError)] + public void AbStatus_maps_to_opcua_codes(AbStatus status, uint expected) { - AbLegacyStatusMapper.MapLibplctagStatus(status).ShouldBe(expected); + AbLegacyStatusMapper.MapStatus(status).ShouldBe(expected); // The int overload must agree — it is the seam IAbLegacyTagRuntime.GetStatus() drives. - AbLegacyStatusMapper.MapLibplctagStatus((int)status).ShouldBe(expected); + AbLegacyStatusMapper.MapStatus((int)status).ShouldBe(expected); + } + + /// MapException reads the status off an AbTransportException, else falls back to comms error. + [Fact] + public void MapException_uses_transport_status() + { + AbLegacyStatusMapper.MapException(new AbTransportException(AbStatus.Timeout, "t")) + .ShouldBe(AbLegacyStatusMapper.BadTimeout); + AbLegacyStatusMapper.MapException(new InvalidOperationException("x")) + .ShouldBe(AbLegacyStatusMapper.BadCommunicationError); + } + + /// + /// The regression oracle: the OPC UA code the pre-seam MapLibplctagStatus(Status) returned, + /// copied verbatim from the deleted switch. For every libplctag member the new + /// translation + must reproduce it. + /// + private static uint OldMapLibplctagStatus(Status status) => status switch + { + Status.Ok => AbLegacyStatusMapper.Good, + Status.Pending => AbLegacyStatusMapper.GoodMoreData, + Status.ErrorTimeout => AbLegacyStatusMapper.BadTimeout, + Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbLegacyStatusMapper.BadNodeIdUnknown, + Status.ErrorNotAllowed => AbLegacyStatusMapper.BadNotWritable, + Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => AbLegacyStatusMapper.BadOutOfRange, + Status.ErrorUnsupported or Status.ErrorNotImplemented => AbLegacyStatusMapper.BadNotSupported, + _ => AbLegacyStatusMapper.BadCommunicationError, + }; + + /// Every libplctag Status member maps to the same OPC UA code as before the seam. + [Fact] + public void Translation_plus_mapper_matches_the_old_libplctag_switch_for_every_member() + { + foreach (var s in Enum.GetValues()) + { + AbLegacyStatusMapper.MapStatus(LibplctagLegacyStatusTranslation.ToAbStatus(s)) + .ShouldBe(OldMapLibplctagStatus(s), $"libplctag Status.{s}"); + } } /// Verifies that timeout errors are distinguished from generic communication errors. [Fact] - public void MapLibplctagStatus_distinguishes_timeout_from_generic_comms_error() + public void MapStatus_distinguishes_timeout_from_generic_comms_error() { - // Regression for Driver.AbLegacy-010: timeout must not fall through to - // BadCommunicationError the way the old magic-integer switch did. - AbLegacyStatusMapper.MapLibplctagStatus((int)Status.ErrorTimeout) - .ShouldBe(AbLegacyStatusMapper.BadTimeout); - AbLegacyStatusMapper.MapLibplctagStatus((int)Status.ErrorNotFound) - .ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown); + AbLegacyStatusMapper.MapStatus((int)AbStatus.Timeout).ShouldBe(AbLegacyStatusMapper.BadTimeout); + AbLegacyStatusMapper.MapStatus((int)AbStatus.NotFound).ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadWriteTests.cs index 45d36552..e047a267 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadWriteTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadWriteTests.cs @@ -1,4 +1,4 @@ -using libplctag; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -70,12 +70,12 @@ public sealed class AbLegacyReadWriteTests [Fact] public async Task NonZero_libplctag_status_maps_via_AbLegacyStatusMapper() { - // Use the real libplctag.Status enum value rather than a raw integer so the test + // Use the real AbStatus enum value rather than a raw integer so the test // stays correct if the wrapper renumbers its ordinals (Driver.AbLegacy-010). var (drv, factory) = NewDriver( new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", "N7:0", AbLegacyDataType.Int)); await drv.InitializeAsync("{}", CancellationToken.None); - factory.Customise = p => new FakeAbLegacyTag(p) { Status = (int)Status.ErrorNotFound }; + factory.Customise = p => new FakeAbLegacyTag(p) { Status = (int)AbStatus.NotFound }; var snapshots = await drv.ReadAsync(["X"], CancellationToken.None); snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitConfigAdoptionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitConfigAdoptionTests.cs index 4aaa3feb..1894c9c9 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitConfigAdoptionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReinitConfigAdoptionTests.cs @@ -39,9 +39,12 @@ public sealed class AbLegacyReinitConfigAdoptionTests [Fact] public async Task Reinitialize_with_an_empty_document_keeps_the_constructor_options() { + // Pinned to libplctag (Task 22): this init starts the default probe loop against 10.0.0.1 with no + // injected factory, and was written and proven on libplctag. Pinning keeps it off the process-wide + // native DeviceSessionPool rather than silently changing what it exercises. var driver = AbLegacyDriverFactoryExtensions.CreateInstance( "ab1", - """{"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}]}""", + """{"transport":"Libplctag","devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}]}""", loggerFactory: null); await Should.NotThrowAsync(() => driver.ReinitializeAsync("{}", CancellationToken.None)); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportInitLogTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportInitLogTests.cs new file mode 100644 index 00000000..e85a45d0 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportInitLogTests.cs @@ -0,0 +1,113 @@ +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// The transport the instance runs on is logged at every init through the HOST-supplied logger factory, +/// so an operator can tell from the node log which stack serves a driver (T19 live gate). The factory +/// path is the one under test: a driver built without a logger writes to the null logger. +/// +[Trait("Category", "Unit")] +public sealed class AbLegacyTransportInitLogTests +{ + private static string Config(string? transport, string family = "Slc500") => + $$"""{{{(transport is null ? "" : $"\"transport\":\"{transport}\",")}}"probe":{"enabled":false},"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"{{family}}"}]}"""; + + /// The init line names the configured-vs-effective transport (final review I3: default = Libplctag). + [Theory] + [InlineData("Native", "Slc500", "Native (configured)")] + [InlineData("Libplctag", "Slc500", "Libplctag (configured)")] + [InlineData(null, "Slc500", "Libplctag (default)")] + [InlineData(null, "Plc5", "Libplctag (default)")] + public async Task Init_logs_the_transport_through_the_registered_logger_factory( + string? json, string family, string expected) + { + var captured = await InitAndCaptureAsync(Config(json, family)); + captured.ShouldContain(m => m.Message.Contains("drv-t19") && m.Message.Contains($"transport={expected}")); + } + + /// + /// An explicit Native against a PLC-5 / LogixPccc device is an allowed operator choice, but logs ONE + /// Warning naming the limitation. The defaulted and explicit-libplctag rows are the falsification + /// controls: same device families, no warning. + /// + [Theory] + [InlineData("Native", "Plc5", true)] + [InlineData("Native", "LogixPccc", true)] + [InlineData(null, "Plc5", false)] + [InlineData(null, "Slc500", false)] + [InlineData("Libplctag", "LogixPccc", false)] + [InlineData("Native", "Slc500", false)] + public async Task Explicit_Native_on_a_limited_family_warns_once(string? json, string family, bool warns) + { + var captured = await InitAndCaptureAsync(Config(json, family)); + var warnings = captured.Where(m => m.Level == LogLevel.Warning && m.Message.Contains("native support is limited")) + .ToList(); + warnings.Count.ShouldBe(warns ? 1 : 0); + if (warns) warnings[0].Message.ShouldContain(family); + } + + /// + /// The Warning is once per INSTANCE, not per init: every comms-loss reconnect retry re-runs init + /// (Task 22 review r1 M4). Three inits, one warning. + /// + [Fact] + public async Task The_limited_family_warning_is_logged_once_per_instance_across_reinits() + { + var config = Config("Native", "Plc5"); + var captured = new List<(LogLevel, string)>(); + using var factory = new CapturingLoggerFactory(captured); + var driver = AbLegacyDriverFactoryExtensions.CreateInstance("drv-t19", config, factory); + try + { + await driver.InitializeAsync(config, TestContext.Current.CancellationToken); + await driver.ReinitializeAsync(config, TestContext.Current.CancellationToken); + await driver.ReinitializeAsync(config, TestContext.Current.CancellationToken); + } + finally + { + await driver.ShutdownAsync(TestContext.Current.CancellationToken); + } + + List<(LogLevel Level, string Message)> snapshot; + lock (captured) snapshot = [.. captured]; + snapshot.Count(m => m.Message.Contains("initializing")).ShouldBe(3); // positive control: three inits ran + snapshot.Count(m => m.Level == LogLevel.Warning && m.Message.Contains("native support is limited")).ShouldBe(1); + } + + private static async Task> InitAndCaptureAsync(string config) + { + var captured = new List<(LogLevel, string)>(); + using var factory = new CapturingLoggerFactory(captured); + var driver = AbLegacyDriverFactoryExtensions.CreateInstance("drv-t19", config, factory); + try + { + await driver.InitializeAsync(config, TestContext.Current.CancellationToken); + } + finally + { + await driver.ShutdownAsync(TestContext.Current.CancellationToken); + } + lock (captured) return [.. captured]; + } + + private sealed class CapturingLoggerFactory(List<(LogLevel, string)> sink) : ILoggerFactory + { + public ILogger CreateLogger(string categoryName) => new CapturingLogger(sink); + public void AddProvider(ILoggerProvider provider) { } + public void Dispose() { } + } + + private sealed class CapturingLogger(List<(LogLevel, string)> sink) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + lock (sink) sink.Add((logLevel, formatter(state, exception))); + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs new file mode 100644 index 00000000..249c4b8c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs @@ -0,0 +1,229 @@ +using System.Net; +using System.Net.Sockets; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// The per-driver Transport switch: config parsing, driver factory selection and the probe. +[Trait("Category", "Unit")] +public sealed class AbLegacyTransportSwitchTests +{ + private const string Device = """{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"Slc500"}"""; + + private static string Config(string? transportJson) => + $$"""{{{(transportJson is null ? "" : $"\"transport\":{transportJson},")}}"devices":[{{Device}}]}"""; + + private static string Config(string? transportJson, params string[] families) + { + var devices = string.Join(",", families.Select((f, i) => + $"{{\"hostAddress\":\"ab://10.0.0.{i + 1}:44818/1,0\",\"plcFamily\":\"{f}\"}}")); + var transport = transportJson is null ? "" : $"\"transport\":{transportJson},"; + return $"{{{transport}\"devices\":[{devices}]}}"; + } + + /// + /// Transport values select the factory type; absent parses to null ("use the default") and the driver + /// resolves that to Libplctag (final review I3 — Native is opt-in). An explicit Native still wins. + /// + [Theory] + [InlineData("\"Native\"", AbLegacyTransport.Native)] + [InlineData("\"native\"", AbLegacyTransport.Native)] + [InlineData("\"Libplctag\"", AbLegacyTransport.Libplctag)] + [InlineData(null, AbLegacyTransport.Libplctag)] + public void Config_transport_selects_the_factory(string? json, AbLegacyTransport expected) + { + var parsed = AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config(json)); + if (json is null) parsed.Transport.ShouldBeNull(); + else parsed.Transport.ShouldBe(expected); + + var driver = AbLegacyDriverFactoryExtensions.CreateInstance("ab1", Config(json)); + driver.EffectiveTransport.ShouldBe(expected); + if (expected == AbLegacyTransport.Native) + driver.TagFactory.ShouldBeOfType(); + else + driver.TagFactory.ShouldBeOfType(); + } + + /// + /// Final review I3: an absent key resolves EVERY family to libplctag — the SLC/MicroLogix rows are the + /// ones that change (they defaulted Native before), so they are the falsification rows: reverting the + /// resolver to "Native unless PLC-5/LogixPccc" turns them red. + /// + [Theory] + [InlineData("Plc5")] + [InlineData("LogixPccc")] + [InlineData("Slc500", "Plc5")] + [InlineData("Slc500")] + [InlineData("MicroLogix")] + [InlineData("Slc500", "MicroLogix")] + public void Absent_transport_resolves_libplctag_for_every_family(params string[] families) + { + var json = Config(null, families); + AbLegacyTransportResolver.Resolve(AbLegacyDriverFactoryExtensions.ParseOptions("ab1", json)) + .ShouldBe(AbLegacyTransport.Libplctag); + + var driver = AbLegacyDriverFactoryExtensions.CreateInstance("ab1", json); + driver.EffectiveTransport.ShouldBe(AbLegacyTransport.Libplctag); + driver.TagFactory.ShouldBeOfType(); + } + + /// The init-log rendering names the default as such, and an explicit choice as configured. + [Fact] + public void Describe_names_default_and_configured() + { + AbLegacyTransportResolver.Describe(null).ShouldBe("Libplctag (default)"); + AbLegacyTransportResolver.Describe(AbLegacyTransport.Native).ShouldBe("Native (configured)"); + AbLegacyTransportResolver.Describe(AbLegacyTransport.Libplctag).ShouldBe("Libplctag (configured)"); + } + + /// An explicit value always wins, for every family. + [Theory] + [InlineData("\"Native\"", "Plc5", AbLegacyTransport.Native)] + [InlineData("\"Native\"", "LogixPccc", AbLegacyTransport.Native)] + [InlineData("\"Native\"", "Slc500", AbLegacyTransport.Native)] + [InlineData("\"Libplctag\"", "Slc500", AbLegacyTransport.Libplctag)] + public void Explicit_transport_wins_over_the_exclusion(string json, string family, AbLegacyTransport expected) + { + var driver = AbLegacyDriverFactoryExtensions.CreateInstance("ab1", Config(json, family)); + driver.EffectiveTransport.ShouldBe(expected); + } + + /// A bad value fails the way an existing bad enum field (PlcFamily) does. + [Fact] + public void Bad_transport_fails_like_a_bad_PlcFamily() + { + var bad = Should.Throw( + () => AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config("\"Bogus\""))); + bad.Message.ShouldContain("unknown Transport 'Bogus'"); + + var existing = Should.Throw(() => AbLegacyDriverFactoryExtensions.ParseOptions( + "ab1", """{"devices":[{"hostAddress":"ab://10.0.0.1/1,0","plcFamily":"Bogus"}]}""")); + existing.GetType().ShouldBe(bad.GetType()); + existing.Message.ShouldContain("unknown PlcFamily 'Bogus'"); + } + + /// + /// An undefined integer string is rejected, not silently run on a switch's default arm (Task 22 review + /// r1 M2): Enum.TryParse alone accepts "7". A defined numeric ("1") still parses, as before. + /// + [Fact] + public void An_undefined_numeric_transport_is_rejected() + { + Should.Throw( + () => AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config("\"7\""))) + .Message.ShouldContain("unknown Transport '7'"); + AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config("\"Native\"")).Transport.ShouldBe(AbLegacyTransport.Native); + } + + /// An explicitly injected factory wins over the option. + [Fact] + public void Injected_factory_wins_over_the_option() + { + var fake = new FakeAbLegacyTagFactory(); + var driver = new AbLegacyDriver(new AbLegacyDriverOptions { Transport = AbLegacyTransport.Native }, "ab1", fake); + driver.TagFactory.ShouldBeSameAs(fake); + } + + /// + /// Reinit does NOT swap transports (by design): the factory is constructor-held, so a changed + /// transport in the reinit JSON is inert. A deploy recreates the instance instead (#516). + /// + [Fact] + public async Task Reinit_with_a_changed_transport_keeps_the_constructed_transport() + { + var driver = AbLegacyDriverFactoryExtensions.CreateInstance("ab1", Config("\"Libplctag\"")); + var before = driver.TagFactory; + + await driver.ReinitializeAsync(Config("\"Native\""), TestContext.Current.CancellationToken); + + driver.TagFactory.ShouldBeSameAs(before); + driver.TagFactory.ShouldBeOfType(); + } + + /// + /// The probe's Phase 1 TCP preflight rejects a refused port before any runtime exists, so the native + /// path is proven against a listener that accepts TCP and hangs up: only a runtime that really opens + /// an EtherNet/IP session reports the native transport's own failure text. + /// + [Fact] + public async Task Probe_with_Native_uses_the_native_path() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + _ = Task.Run(async () => + { + while (true) + { + try { (await listener.AcceptTcpClientAsync(TestContext.Current.CancellationToken)).Dispose(); } + catch { return; } + } + }, TestContext.Current.CancellationToken); + + string Cfg(string t) => + $$"""{"transport":"{{t}}","devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0"}]}"""; + var probe = new AbLegacyDriverProbe(); + + var native = await probe.ProbeAsync(Cfg("Native"), TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + var lib = await probe.ProbeAsync(Cfg("Libplctag"), TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + listener.Stop(); + + native.Ok.ShouldBeFalse(); + native.Message.ShouldNotBeNull(); + // "Connection lost" is the managed EncapsulationClient's wording; libplctag reports a bare status name. + native.Message!.ShouldContain("PCCC handshake failed: Connection lost"); + lib.Message!.ShouldNotContain("Connection lost"); + } + + /// + /// The probe resolves an ABSENT transport through the same resolver as the driver: an SLC 500 with no + /// key takes libplctag (no native failure text) exactly as a PLC-5 does, while an explicit Native on the + /// same listener produces the native text — the control that proves the text discriminates. + /// + [Fact] + public async Task Probe_with_no_transport_resolves_like_the_driver() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + _ = Task.Run(async () => + { + while (true) + { + try { (await listener.AcceptTcpClientAsync(TestContext.Current.CancellationToken)).Dispose(); } + catch { return; } + } + }, TestContext.Current.CancellationToken); + + string Cfg(string family) => + $$"""{"devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0","plcFamily":"{{family}}"}]}"""; + var nativeCfg = + $$"""{"transport":"Native","devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0","plcFamily":"Slc500"}]}"""; + var probe = new AbLegacyDriverProbe(); + + var slc = await probe.ProbeAsync(Cfg("Slc500"), TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + var plc5 = await probe.ProbeAsync(Cfg("Plc5"), TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + var native = await probe.ProbeAsync(nativeCfg, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + listener.Stop(); + + AbLegacyTransportResolver.Resolve(AbLegacyDriverFactoryExtensions.ParseOptions("p", Cfg("Slc500"))) + .ShouldBe(AbLegacyTransport.Libplctag); + AbLegacyTransportResolver.Resolve(AbLegacyDriverFactoryExtensions.ParseOptions("p", Cfg("Plc5"))) + .ShouldBe(AbLegacyTransport.Libplctag); + slc.Ok.ShouldBeFalse(); + slc.Message!.ShouldNotContain("Connection lost"); + plc5.Message!.ShouldNotContain("Connection lost"); + native.Message!.ShouldContain("PCCC handshake failed: Connection lost"); + } + + /// The probe deserialises with its own string-enum converter; a bad transport is a config error. + [Fact] + public async Task Probe_with_a_bad_transport_reports_invalid_config() + { + var r = await new AbLegacyDriverProbe().ProbeAsync( + Config("\"Bogus\""), TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + r.Ok.ShouldBeFalse(); + r.Message!.ShouldContain("Config JSON is invalid"); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagEmptyPathTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagEmptyPathTests.cs new file mode 100644 index 00000000..9871bfa4 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagEmptyPathTests.cs @@ -0,0 +1,37 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// libplctag rejects an empty-string path with ErrorBadData; an unrouted MicroLogix +/// (ab://host:44818/) must reach it as null. +/// +public sealed class LibplctagEmptyPathTests +{ + private static AbLegacyTagCreateParams Params(string cipPath) => + new(Gateway: "10.100.0.35", Port: 44818, CipPath: cipPath, LibplctagPlcAttribute: "micrologix", + TagName: "N7:0", Timeout: TimeSpan.FromSeconds(5)); + + [Theory] + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData(null, null)] + [InlineData("1,0", "1,0")] + public void Normalize_maps_blank_to_null_and_keeps_a_route(string? input, string? expected) => + LibplctagPath.Normalize(input).ShouldBe(expected); + + [Fact] + public void The_data_tag_omits_the_path_for_an_empty_route() + { + using var tag = LibplctagLegacyTagRuntime.BuildTag(Params("")); + tag.Path.ShouldBeNull(); + } + + [Fact] + public void The_data_tag_keeps_a_real_route() + { + using var tag = LibplctagLegacyTagRuntime.BuildTag(Params("1,0")); + tag.Path.ShouldBe("1,0"); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyOrphanReleaseTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyOrphanReleaseTests.cs new file mode 100644 index 00000000..913eff67 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyOrphanReleaseTests.cs @@ -0,0 +1,63 @@ +using libplctag.NativeImport; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// T21 press pass 1, AbLegacy side: a failed libplctag initialisation must destroy the native tag it created +/// (libplctag.NET 1.5.2's Dispose() skips an uninitialised tag, leaving a native tag whose callback can fire +/// into a collected delegate and kill the process). See LibplctagOrphanRelease. +/// +public sealed class LibplctagLegacyOrphanReleaseTests +{ + [Fact] + public void The_pinned_libplctag_wrapper_still_exposes_the_native_handle() => + LibplctagOrphanRelease.CanReadHandles.ShouldBeTrue(); + + /// + /// Final review M2: the handle field's TYPE is pinned explicitly, not just its presence. A retype (IntPtr, + /// long) now degrades to the rooting fallback instead of an InvalidCastException inside a catch block, + /// and this names the reason when it happens. + /// + [Fact] + public void The_pinned_wrapper_handle_field_is_still_an_int() + { + const System.Reflection.BindingFlags priv = + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance; + var wrapper = typeof(libplctag.Tag).GetField("_tag", priv).ShouldNotBeNull(); + wrapper.FieldType.GetField("nativeTagHandle", priv).ShouldNotBeNull().FieldType.ShouldBe(typeof(int)); + } + + /// HandleOf never throws (it runs inside the runtimes' catch blocks); a never-created tag reads 0. + [Fact] + public void HandleOf_a_fresh_tag_is_zero_and_does_not_throw() + { + using var tag = new libplctag.Tag(); + LibplctagOrphanRelease.HandleOf(tag).ShouldBe(0); + } + + [Fact] + public async Task A_failed_initialisation_destroys_the_native_tag_it_created() + { + var rt = new LibplctagLegacyTagRuntime(new AbLegacyTagCreateParams( + Gateway: "127.0.0.1", Port: 1, CipPath: "1,0", LibplctagPlcAttribute: "slc500", + TagName: "N7:0", Timeout: TimeSpan.FromMilliseconds(300))); + try + { + await Should.ThrowAsync(() => rt.InitializeAsync(CancellationToken.None)); + var handle = rt.NativeHandle; + handle.ShouldBeGreaterThan(0, "plc_tag_create_ex returned a handle before the failure"); + var exists = plctag.plc_tag_lock(handle) == 0; + if (exists) plctag.plc_tag_unlock(handle); + exists.ShouldBeFalse("the failed tag's native handle must be destroyed, not orphaned"); + rt.GetStatus().ShouldNotBe((int)AbStatus.NotFound); + rt.WrapperHandle.ShouldBe(0); // review r1 M2: a repeat release cannot touch the destroyed id + } + finally + { + rt.Dispose(); + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyStatusTranslationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyStatusTranslationTests.cs new file mode 100644 index 00000000..9d396696 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyStatusTranslationTests.cs @@ -0,0 +1,36 @@ +using libplctag; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +public sealed class LibplctagLegacyStatusTranslationTests +{ + [Theory] + [InlineData(Status.Ok, AbStatus.Ok)] + [InlineData(Status.Pending, AbStatus.Pending)] + [InlineData(Status.ErrorTimeout, AbStatus.Timeout)] + [InlineData(Status.ErrorNotFound, AbStatus.NotFound)] + [InlineData(Status.ErrorNoMatch, AbStatus.NotFound)] + [InlineData(Status.ErrorBadDevice, AbStatus.NotFound)] + [InlineData(Status.ErrorNotAllowed, AbStatus.NotAllowed)] + [InlineData(Status.ErrorOutOfBounds, AbStatus.OutOfBounds)] + [InlineData(Status.ErrorTooLarge, AbStatus.TooLarge)] + [InlineData(Status.ErrorTooSmall, AbStatus.TooSmall)] + [InlineData(Status.ErrorUnsupported, AbStatus.Unsupported)] + [InlineData(Status.ErrorNotImplemented, AbStatus.Unsupported)] + [InlineData(Status.ErrorPartial, AbStatus.Partial)] + [InlineData(Status.ErrorAbort, AbStatus.Abort)] + [InlineData(Status.ErrorBadReply, AbStatus.BadReply)] + [InlineData(Status.ErrorBadGateway, AbStatus.BadConnection)] + [InlineData(Status.ErrorWinsock, AbStatus.BadConnection)] + public void Translates(Status from, AbStatus to) => LibplctagLegacyStatusTranslation.ToAbStatus(from).ShouldBe(to); + + [Fact] + public void Exception_message_is_recovered_as_the_enum_name() + { + LibplctagLegacyStatusTranslation.ToAbStatus(new LibPlcTagException("ErrorNotFound")).ShouldBe(AbStatus.NotFound); + LibplctagLegacyStatusTranslation.ToAbStatus(new LibPlcTagException("gibberish")).ShouldBe(AbStatus.BadConnection); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs new file mode 100644 index 00000000..40320025 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs @@ -0,0 +1,633 @@ +using System.Buffers.Binary; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +[Trait("Category", "Unit")] +public sealed class NativeAbLegacyTagRuntimeTests +{ + // The requestor identity libplctag used in the golden captures, so our request bytes can be compared + // to the captured ones apart from the TNS. + private const ushort Vendor = 0xF33D; + private const uint Serial = 0x21504345; + + private sealed class Harness : IAsyncDisposable + { + public FakeCipChannel Channel { get; } = new(); + public RequestScheduler Scheduler { get; } + public NativeAbLegacyTagRuntime Runtime { get; } + + public Harness(string plc, string tag, int count = 1) + { + Scheduler = new RequestScheduler(Channel, allowPacking: false); + Runtime = new NativeAbLegacyTagRuntime( + new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", plc, tag, TimeSpan.FromSeconds(2), count), + Scheduler, Vendor, Serial); + } + + /// Answers every request with , echoing the request's requestor id and TNS. + public Harness Replies(byte[] data) => Responder((_, _) => (0x00, null, data)); + + public Harness Responder(Func f) + { + var n = 0; + Channel.Respond = req => + { + var (sts, ext, data) = f(req, n++); + return Task.FromResult(Echo(req, sts, ext, data)); + }; + return this; + } + + public async ValueTask DisposeAsync() + { + Runtime.Dispose(); + await Scheduler.DisposeAsync(); + } + } + + // Execute PCCC request: 4B 02 20 67 24 01 | requestor id (7) | cmd 0F, sts 00, tns(2), fnc, ... + private static ushort TnsOf(byte[] req) => BinaryPrimitives.ReadUInt16LittleEndian(req.AsSpan(15)); + private static byte FncOf(byte[] req) => req[17]; + + private static byte[] Echo(byte[] req, byte sts, byte? ext, byte[] data) + { + var body = new List(req[6..13]) { 0x4F, sts, req[15], req[16] }; + if (ext is { } e) body.Add(e); + body.AddRange(data); + return FakeCipChannel.Reply(0x4B, 0x00, [.. body]); + } + + private static byte[] Words(params ushort[] w) + { + var b = new byte[w.Length * 2]; + for (var i = 0; i < w.Length; i++) BinaryPrimitives.WriteUInt16LittleEndian(b.AsSpan(i * 2), w[i]); + return b; + } + + /// The PCCC data bytes of a golden capture's first reply (after cmd, sts, tns). + private static byte[] GoldenData(string capture) + { + var f = CaptureFile.Load(capture).First(x => !x.FromClient && CaptureFile.CipPayload(x).Length > 0); + return CaptureFile.CipPayload(f)[15..]; + } + + private static void ShouldMatchCaptureExceptTns(string capture, byte[] sent) + { + var golden = CaptureFile.CipPayload(CaptureFile.Load(capture).First(x => x.FromClient && CaptureFile.CipPayload(x).Length > 0)); + sent.Length.ShouldBe(golden.Length); + for (var i = 0; i < golden.Length; i++) + if (i is not (15 or 16)) sent[i].ShouldBe(golden[i], $"byte {i}"); + } + + [Fact] + public async Task N7_0_read_decodes_the_golden_slc500_value_and_sends_the_captured_request() + { + await using var h = new Harness("slc500", "N7:0").Replies(GoldenData("slc500-read-n7.hex")); + await h.Runtime.ReadAsync(CancellationToken.None); + h.Runtime.DecodeValue(AbLegacyDataType.Int, null).ShouldBe(4321); + h.Runtime.GetStatus().ShouldBe(0); + ShouldMatchCaptureExceptTns("slc500-read-n7.hex", h.Channel.Sent.Single()); + } + + [Fact] + public async Task F8_0_read_decodes_the_golden_float() + { + await using var h = new Harness("slc500", "F8:0").Replies(GoldenData("slc500-read-f8.hex")); + await h.Runtime.ReadAsync(CancellationToken.None); + h.Runtime.DecodeValue(AbLegacyDataType.Float, null).ShouldBe(3.5f); + ShouldMatchCaptureExceptTns("slc500-read-f8.hex", h.Channel.Sent.Single()); + } + + [Fact] + public async Task MicroLogix_N7_0_read_decodes_the_golden_value() + { + await using var h = new Harness("micrologix", "N7:0").Replies(GoldenData("micrologix-read-n7.hex")); + await h.Runtime.ReadAsync(CancellationToken.None); + h.Runtime.DecodeValue(AbLegacyDataType.Int, null).ShouldBe(2468); + ShouldMatchCaptureExceptTns("micrologix-read-n7.hex", h.Channel.Sent.Single()); + } + + [Fact] + public async Task Plc5_N7_0_read_uses_the_range_read_and_decodes_the_golden_value() + { + await using var h = new Harness("plc5", "N7:0").Replies(GoldenData("plc5-read-n7.hex")); + await h.Runtime.ReadAsync(CancellationToken.None); + h.Runtime.DecodeValue(AbLegacyDataType.Int, null).ShouldBe(1357); + FncOf(h.Channel.Sent.Single()).ShouldBe((byte)0x01); + ShouldMatchCaptureExceptTns("plc5-read-n7.hex", h.Channel.Sent.Single()); + } + + [Fact] + public async Task B3_0_bit_5_decodes_from_the_little_endian_word() + { + await using var h = new Harness("slc500", "B3:0/5").Replies(Words(0x0020)); + await h.Runtime.ReadAsync(CancellationToken.None); + h.Runtime.DecodeValue(AbLegacyDataType.Bit, 5).ShouldBe(true); + h.Runtime.DecodeValue(AbLegacyDataType.Bit, 4).ShouldBe(false); + h.Runtime.DecodeValue(AbLegacyDataType.Bit, null).ShouldBe(false); // no index: bit 0 of the word + } + + [Fact] + public async Task A_bit_above_15_in_an_L_file_decodes_from_the_32_bit_word() + { + await using var h = new Harness("slc500", "L9:0/20").Replies([0x00, 0x00, 0x10, 0x00]); + await h.Runtime.ReadAsync(CancellationToken.None); + h.Runtime.DecodeValue(AbLegacyDataType.Bit, 20).ShouldBe(true); + h.Runtime.DecodeValue(AbLegacyDataType.Long, null).ShouldBe(0x100000); + } + + [Fact] + public async Task An_N7_array_of_5_reads_ten_bytes_and_decodes_each_element() + { + await using var h = new Harness("slc500", "N7:0", count: 5).Replies(Words(10, 20, 30, 0xFFFF, 50)); + await h.Runtime.ReadAsync(CancellationToken.None); + var req = h.Channel.Sent.Single(); + req[18].ShouldBe((byte)10); // byte count + h.Runtime.DecodeArray(AbLegacyDataType.Int, 5).ShouldBe(new short[] { 10, 20, 30, -1, 50 }); + } + + [Fact] + public async Task A_B_file_array_decodes_bit_packed() + { + await using var h = new Harness("slc500", "B3:0", count: 2).Replies(Words(0x0005, 0x0000)); + await h.Runtime.ReadAsync(CancellationToken.None); + var bits = (bool[])h.Runtime.DecodeArray(AbLegacyDataType.Bit, 4)!; + bits.ShouldBe(new[] { true, false, true, false }); + } + + [Fact] + public async Task T4_0_ACC_addresses_word_2_of_the_timer_and_reads_two_bytes() + { + await using var h = new Harness("slc500", "T4:0.ACC").Replies(Words(500)); + await h.Runtime.ReadAsync(CancellationToken.None); + var req = h.Channel.Sent.Single(); + // 0F 00 tns(2) A2 byteCount file type element sub (file 4, type T = 0x86, element 0, sub-element 2) + req[13..].Where((_, i) => i is 0 or 1 or 4 or 5 or 6 or 7 or 8 or 9).ToArray() + .ShouldBe(new byte[] { 0x0F, 0x00, 0xA2, 0x02, 0x04, 0x86, 0x00, 0x02 }); + h.Runtime.DecodeValue(AbLegacyDataType.Int, null).ShouldBe(500); + } + + [Fact] + public async Task A_whole_timer_reads_six_bytes_and_decodes_the_first_four_as_Int32() + { + await using var h = new Harness("slc500", "T4:1").Replies([0x01, 0x00, 0x02, 0x00, 0x03, 0x00]); + await h.Runtime.ReadAsync(CancellationToken.None); + h.Channel.Sent.Single()[18].ShouldBe((byte)6); + h.Runtime.DecodeValue(AbLegacyDataType.TimerElement, null).ShouldBe(0x00020001); + } + + [Fact] + public async Task A_status_bit_sub_element_decodes_its_bit_from_word_0() + { + await using var h = new Harness("slc500", "T4:0.DN").Replies(Words(1 << 13)); + await h.Runtime.ReadAsync(CancellationToken.None); + h.Channel.Sent.Single()[18].ShouldBe((byte)2); + h.Runtime.DecodeValue(AbLegacyDataType.Bit, null).ShouldBe(true); + } + + [Fact] + public async Task A_string_decodes_with_each_byte_pair_swapped() + { + var data = new byte[84]; + data[0] = 5; // LEN + // "hello" stored pair-swapped: eh ll \0o + data[2] = (byte)'e'; data[3] = (byte)'h'; data[4] = (byte)'l'; data[5] = (byte)'l'; data[6] = 0; data[7] = (byte)'o'; + await using var h = new Harness("slc500", "ST9:0").Replies(data); + await h.Runtime.ReadAsync(CancellationToken.None); + h.Channel.Sent.Single()[18].ShouldBe((byte)84); + h.Runtime.DecodeValue(AbLegacyDataType.String, null).ShouldBe("hello"); + } + + [Fact] + public async Task A_string_write_encodes_len_and_swapped_pairs() + { + await using var h = new Harness("slc500", "ST9:0").Replies([]); + h.Runtime.EncodeValue(AbLegacyDataType.String, null, "hello"); + await h.Runtime.WriteAsync(CancellationToken.None); + var data = h.Channel.Sent.Single()[23..]; + data.Length.ShouldBe(84); + data[0].ShouldBe((byte)5); + data[1].ShouldBe((byte)0); + data[2..8].ShouldBe(new[] { (byte)'e', (byte)'h', (byte)'l', (byte)'l', (byte)0, (byte)'o' }); + } + + [Fact] + public async Task STS_0x20_on_initialize_is_NotFound() + { + await using var h = new Harness("slc500", "N99:0").Responder((_, _) => (0x20, null, [])); + var ex = await Should.ThrowAsync(() => h.Runtime.InitializeAsync(CancellationToken.None)); + ex.Status.ShouldBe(AbStatus.NotFound); + h.Runtime.GetStatus().ShouldBe((int)AbStatus.NotFound); + } + + [Fact] + public async Task Initialize_performs_exactly_one_read() + { + await using var h = new Harness("slc500", "N7:0").Replies(Words(1)); + await h.Runtime.InitializeAsync(CancellationToken.None); + h.Channel.SentCount.ShouldBe(1); + h.Runtime.DecodeValue(AbLegacyDataType.Int, null).ShouldBe(1); + } + + [Fact] + public async Task Writing_N7_0_equals_42_sends_the_expected_PCCC_bytes() + { + await using var h = new Harness("slc500", "N7:0").Replies([]); + h.Runtime.EncodeValue(AbLegacyDataType.Int, null, 42); + await h.Runtime.WriteAsync(CancellationToken.None); + var req = h.Channel.Sent.Single(); + req[..6].ShouldBe(new byte[] { 0x4B, 0x02, 0x20, 0x67, 0x24, 0x01 }); + req[6..13].ShouldBe(new byte[] { 0x07, 0x3D, 0xF3, 0x45, 0x43, 0x50, 0x21 }); + var tns = TnsOf(req); + req[13..].ShouldBe(new byte[] { 0x0F, 0x00, (byte)tns, (byte)(tns >> 8), 0xAA, 0x02, 0x07, 0x89, 0x00, 0x00, 0x2A, 0x00 }); + h.Runtime.GetStatus().ShouldBe(0); + } + + [Fact] + public async Task The_TNS_increments_per_request() + { + await using var h = new Harness("slc500", "N7:0").Replies(Words(1)); + await h.Runtime.ReadAsync(CancellationToken.None); + await h.Runtime.ReadAsync(CancellationToken.None); + TnsOf(h.Channel.Sent[1]).ShouldNotBe(TnsOf(h.Channel.Sent[0])); + } + + [Fact] + public async Task A_256_word_read_is_three_chunks_of_118_118_20_words() + { + await using var h = new Harness("slc500", "N7:0", count: 256).Responder((req, _) => + { + // 0F 00 tns A2 byteCount file type element sub (element < 255 or 0xFF-escaped) + var bytes = req[18]; + var elem = req[21] == 0xFF ? BinaryPrimitives.ReadUInt16LittleEndian(req.AsSpan(22)) : req[21]; + var words = Enumerable.Range(elem, bytes / 2).Select(i => (ushort)i).ToArray(); + return (0x00, null, Words(words)); + }); + await h.Runtime.ReadAsync(CancellationToken.None); + h.Channel.Sent.Select(r => (int)r[18]).ShouldBe(new[] { 236, 236, 40 }); + var arr = (short[])h.Runtime.DecodeArray(AbLegacyDataType.Int, 256)!; + arr.Length.ShouldBe(256); + for (var i = 0; i < 256; i++) arr[i].ShouldBe((short)i); + } + + [Fact] + public async Task A_failure_in_a_later_read_chunk_fails_the_whole_read_and_keeps_the_previous_value() + { + await using var h = new Harness("slc500", "N7:0", count: 256).Responder((_, n) => + n == 1 ? (0x10, null, []) : (0x00, null, new byte[236])); + var ex = await Should.ThrowAsync(() => h.Runtime.ReadAsync(CancellationToken.None)); + ex.Status.ShouldBe(AbStatus.Unsupported); + h.Runtime.GetStatus().ShouldBe((int)AbStatus.Unsupported); + h.Channel.SentCount.ShouldBe(2); + } + + [Fact] + public async Task A_130_word_write_is_two_chunks_of_118_and_12_words() + { + await using var h = new Harness("slc500", "N7:0", count: 130).Replies([]); + h.Runtime.EncodeValue(AbLegacyDataType.Int, null, 7); + await h.Runtime.WriteAsync(CancellationToken.None); + h.Channel.Sent.Select(r => (int)r[18]).ShouldBe(new[] { 236, 24 }); + h.Channel.Sent[0][21].ShouldBe((byte)0); // element 0 + h.Channel.Sent[1][21].ShouldBe((byte)118); // element advances by the words already sent + h.Channel.Sent[0][23].ShouldBe((byte)7); + } + + [Fact] + public async Task A_failed_second_write_chunk_is_reported_as_failed_not_Good() + { + await using var h = new Harness("slc500", "N7:0", count: 130).Responder((_, n) => + n == 1 ? (0x30, null, []) : (0x00, null, [])); + var ex = await Should.ThrowAsync(() => h.Runtime.WriteAsync(CancellationToken.None)); + ex.Status.ShouldBe(AbStatus.NotAllowed); + h.Runtime.GetStatus().ShouldBe((int)AbStatus.NotAllowed); + } + + [Fact] + public async Task A_PLC5_write_is_Unsupported_and_sends_nothing() + { + await using var h = new Harness("plc5", "N7:0").Replies([]); + h.Runtime.EncodeValue(AbLegacyDataType.Int, null, 1); + var ex = await Should.ThrowAsync(() => h.Runtime.WriteAsync(CancellationToken.None)); + ex.Status.ShouldBe(AbStatus.Unsupported); + h.Channel.SentCount.ShouldBe(0); + } + + [Theory] + [InlineData("F8:0")] + [InlineData("B3:0")] + [InlineData("T4:0.ACC")] + public async Task A_PLC5_read_outside_N_files_is_Unsupported_and_sends_nothing(string tag) + { + await using var h = new Harness("plc5", tag).Replies([]); + var ex = await Should.ThrowAsync(() => h.Runtime.ReadAsync(CancellationToken.None)); + ex.Status.ShouldBe(AbStatus.Unsupported); + h.Channel.SentCount.ShouldBe(0); + } + + [Fact] + public async Task A_reply_with_a_different_requestor_id_is_BadReply() + { + await using var h = new Harness("slc500", "N7:0"); + h.Channel.Respond = req => + { + var echoed = Echo(req, 0, null, Words(1)); + echoed[4 + 1] ^= 0xFF; // corrupt the echoed requestor id + return Task.FromResult(echoed); + }; + var ex = await Should.ThrowAsync(() => h.Runtime.ReadAsync(CancellationToken.None)); + ex.Status.ShouldBe(AbStatus.BadReply); + } + + [Fact] + public async Task A_reply_with_a_stale_TNS_is_BadReply() + { + await using var h = new Harness("slc500", "N7:0"); + h.Channel.Respond = req => + { + var echoed = Echo(req, 0, null, Words(1)); + echoed[4 + 7 + 2] ^= 0x01; // flip a TNS bit + return Task.FromResult(echoed); + }; + var ex = await Should.ThrowAsync(() => h.Runtime.ReadAsync(CancellationToken.None)); + ex.Status.ShouldBe(AbStatus.BadReply); + } + + [Fact] + public async Task An_over_long_data_reply_is_TooLarge() + { + await using var h = new Harness("slc500", "N7:0").Replies(Words(1, 2)); + var ex = await Should.ThrowAsync(() => h.Runtime.ReadAsync(CancellationToken.None)); + ex.Status.ShouldBe(AbStatus.TooLarge); + } + + [Theory] + [InlineData("T4:0.DN")] + [InlineData("C5:0.CU")] + [InlineData("R6:0.EN")] + public async Task A_write_to_a_status_bit_sub_element_is_Unsupported_and_sends_nothing(string tag) + { + await using var h = new Harness("slc500", tag).Replies([]); + h.Runtime.EncodeValue(AbLegacyDataType.Bit, null, true); + var ex = await Should.ThrowAsync(() => h.Runtime.WriteAsync(CancellationToken.None)); + ex.Status.ShouldBe(AbStatus.Unsupported); + h.Channel.SentCount.ShouldBe(0); + } + + // These tests call the driver's WriteAsync/ReadAsync directly to pin its OWN status mapping; resilience + // wrapping (CapabilityInvoker) is out of scope and would retry/mask the very status under test. +#pragma warning disable OTOPCUA0001 + /// Native runtimes over the harness channel; counts creates so a test can prove a handle was KEPT. + private sealed class HarnessFactory(Harness h) : IAbLegacyTagFactory + { + public int Creates; + + public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p) + { + Interlocked.Increment(ref Creates); + return new NativeAbLegacyTagRuntime(p, h.Scheduler, Vendor, Serial); + } + } + + private const string Host = "ab://10.0.0.1:44818/1,0"; + + private static async Task DriverAsync( + HarnessFactory factory, AbLegacyPlcFamily family, AbLegacyTagDefinition tag) + { + var driver = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(Host, family)], + RawTags = AbLegacyRawTags.Entries([tag]), + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "drv-sb", factory); + await driver.InitializeAsync("{}", CancellationToken.None); + return driver; + } + + /// + /// Task 22 review r1 / #571: a native status-bit sub-element write is refused locally and reaches the + /// client as BadNotSupported — NOT a comms fault: nothing on the wire, the tag handle is kept (no + /// re-create), and driver health is untouched. The tag is read first so its handle exists and the + /// wire count isolates the write. + /// + [Theory] + [InlineData("T4:0.DN")] + [InlineData("C5:0.CU")] + [InlineData("R6:0.EN")] + public async Task A_status_bit_write_is_BadNotSupported_without_wire_eviction_or_degraded_health(string address) + { + await using var h = new Harness("slc500", "N7:0").Replies(Words(0)); + var factory = new HarnessFactory(h); + var driver = await DriverAsync(factory, AbLegacyPlcFamily.Slc500, + new AbLegacyTagDefinition("Bit", Host, address, AbLegacyDataType.Bit)); + (await driver.ReadAsync(["Bit"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + var health = driver.GetHealth(); + var sent = h.Channel.SentCount; + + var results = await driver.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None); + + results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); + h.Channel.SentCount.ShouldBe(sent); // zero wire requests for the refused write + driver.GetHealth().ShouldBe(health); // health unchanged (not Degraded) + (await driver.ReadAsync(["Bit"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + factory.Creates.ShouldBe(1); // handle kept: the read after reused it + await driver.ShutdownAsync(CancellationToken.None); + } + + /// + /// #571, read side: a native PLC-5 non-N read is refused locally — BadNotSupported, nothing on the wire, + /// health unchanged, and the refused handle is KEPT (one create across repeated reads, so a poll does + /// not re-lease the device connection every cycle). + /// + [Fact] + public async Task A_PLC5_non_N_read_is_BadNotSupported_without_wire_eviction_or_degraded_health() + { + await using var h = new Harness("plc5", "N7:0").Replies(Words(0)); + var factory = new HarnessFactory(h); + var driver = await DriverAsync(factory, AbLegacyPlcFamily.Plc5, + new AbLegacyTagDefinition("F", Host, "F8:0", AbLegacyDataType.Float)); + var health = driver.GetHealth(); + + for (var n = 0; n < 3; n++) + (await driver.ReadAsync(["F"], CancellationToken.None)).Single().StatusCode + .ShouldBe(AbLegacyStatusMapper.BadNotSupported); + + h.Channel.SentCount.ShouldBe(0); + driver.GetHealth().ShouldBe(health); + factory.Creates.ShouldBe(1); + await driver.ShutdownAsync(CancellationToken.None); + } + + /// #571, write side: a native PLC-5 write is refused locally — BadNotSupported, no write on the wire, + /// handle kept, health unchanged. + [Fact] + public async Task A_PLC5_write_is_BadNotSupported_without_wire_eviction_or_degraded_health() + { + await using var h = new Harness("plc5", "N7:0").Replies(Words(0)); + var factory = new HarnessFactory(h); + var driver = await DriverAsync(factory, AbLegacyPlcFamily.Plc5, + new AbLegacyTagDefinition("N", Host, "N7:0", AbLegacyDataType.Int)); + (await driver.ReadAsync(["N"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + var health = driver.GetHealth(); + var sent = h.Channel.SentCount; + + (await driver.WriteAsync([new WriteRequest("N", (short)5)], CancellationToken.None)).Single().StatusCode + .ShouldBe(AbLegacyStatusMapper.BadNotSupported); + + h.Channel.SentCount.ShouldBe(sent); + driver.GetHealth().ShouldBe(health); + (await driver.ReadAsync(["N"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + factory.Creates.ShouldBe(1); + await driver.ShutdownAsync(CancellationToken.None); + } + + /// Positive control for the wire-count assertions above: the same driver path for a WORD sub-element + /// does send a typed write (fnc 0xAA), so the harness can see one. + [Fact] + public async Task A_word_sub_element_write_through_the_driver_sends_a_typed_write() + { + await using var h = new Harness("slc500", "N7:0").Replies(Words(0)); + var driver = await DriverAsync(new HarnessFactory(h), AbLegacyPlcFamily.Slc500, + new AbLegacyTagDefinition("Pre", Host, "T4:0.PRE", AbLegacyDataType.Int)); + + var results = await driver.WriteAsync([new WriteRequest("Pre", (short)5)], CancellationToken.None); + + results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + lock (h.Channel.Sent) h.Channel.Sent.ShouldContain(r => FncOf(r) == 0xAA); + await driver.ShutdownAsync(CancellationToken.None); + } + +#pragma warning restore OTOPCUA0001 + + [Theory] + [InlineData("T4:0.PRE")] + [InlineData("T4:0.ACC")] + [InlineData("R6:0.LEN")] + [InlineData("R6:0.POS")] + public async Task A_write_to_a_word_sub_element_still_works(string tag) + { + await using var h = new Harness("slc500", tag).Replies([]); + h.Runtime.EncodeValue(AbLegacyDataType.Int, null, 9); + await h.Runtime.WriteAsync(CancellationToken.None); + h.Channel.SentCount.ShouldBe(1); + } + + [Theory] + [InlineData(AbLegacyDataType.Bit)] + [InlineData(AbLegacyDataType.Int)] + [InlineData(AbLegacyDataType.AnalogInt)] + [InlineData(AbLegacyDataType.Long)] + [InlineData(AbLegacyDataType.TimerElement)] + [InlineData(AbLegacyDataType.CounterElement)] + [InlineData(AbLegacyDataType.ControlElement)] + public async Task Every_scalar_type_decodes_a_status_bit_sub_element_as_that_bit(AbLegacyDataType type) + { + foreach (var (word, expectSet) in new[] { ((ushort)0xE000, true), ((ushort)0x0000, false) }) + { + await using var h = new Harness("slc500", "T4:0.DN").Replies(Words(word)); + await h.Runtime.ReadAsync(CancellationToken.None); + var v = h.Runtime.DecodeValue(type, null); + if (type == AbLegacyDataType.Bit) v.ShouldBe(expectSet); + else v.ShouldBe(expectSet ? 1 : 0); + } + } + + [Fact] + public async Task A_short_data_reply_is_BadReply() + { + await using var h = new Harness("slc500", "N7:0", count: 5).Replies(Words(1, 2)); + var ex = await Should.ThrowAsync(() => h.Runtime.ReadAsync(CancellationToken.None)); + ex.Status.ShouldBe(AbStatus.BadReply); + } + + [Fact] + public async Task Bit_with_an_index_cannot_be_encoded() + { + await using var h = new Harness("slc500", "B3:0/5"); + Should.Throw(() => h.Runtime.EncodeValue(AbLegacyDataType.Bit, 5, true)); + } + + [Fact] + public async Task An_unparseable_address_fails_the_operation_as_Unsupported() + { + await using var h = new Harness("slc500", "not-an-address"); + var ex = await Should.ThrowAsync(() => h.Runtime.InitializeAsync(CancellationToken.None)); + ex.Status.ShouldBe(AbStatus.Unsupported); + } + + [Theory] + [InlineData("slc500", CipRouting.Direct)] + [InlineData("micrologix", CipRouting.Direct)] + [InlineData("plc5", CipRouting.Direct)] + // Captures/README.md "Routing observed": logixpccc sends Unconnected Send (0x52) with route 01 00. + [InlineData("logixpccc", CipRouting.UnconnectedSend)] + public void Routing_follows_the_captures_per_family(string plc, CipRouting expected) + { + NativeAbLegacyTagRuntime.RoutingFor(plc).ShouldBe(expected); + var p = new AbLegacyTagCreateParams("h", 44818, "1,0", plc, "N7:0", TimeSpan.FromSeconds(3)); + var o = NativeAbLegacyTagRuntime.OptionsFor(p); + o.Routing.ShouldBe(expected); + o.ConnectionSize.ShouldBe(504); + o.AllowPacking.ShouldBeFalse(); + o.Timeout.ShouldBe(TimeSpan.FromSeconds(3)); + } + + [Fact] + public void The_device_key_defaults_the_port_to_44818() + { + var p = new AbLegacyTagCreateParams("plc1", 0, "1,0", "slc500", "N7:0", TimeSpan.FromSeconds(3)); + NativeAbLegacyTagRuntime.KeyFor(p).ShouldBe(new DeviceKey("plc1", 44818, "1,0", "slc500")); + var q = p with { Port = 2222 }; + NativeAbLegacyTagRuntime.KeyFor(q).Port.ShouldBe(2222); + } + + [Fact] + public async Task The_factory_leases_the_pooled_connection_and_releases_it_on_dispose() + { + var pool = new DeviceSessionPool(); + var factory = new NativeAbLegacyTagFactory(pool); + var p = new AbLegacyTagCreateParams("127.0.0.1", 1, "1,0", "slc500", "N7:0", TimeSpan.FromSeconds(1)); + var a = factory.Create(p); + var b = factory.Create(p with { TagName = "N7:1" }); + a.ShouldBeOfType(); + a.Dispose(); + b.Dispose(); + await Task.Yield(); + } + + [Fact] + public async Task A_pooled_runtime_sends_libplctags_requestor_id_and_accepts_ab_servers_fixed_echo() + { + // ab_server answers every PCCC request with the fixed prefix CB 00 00 00 07 3D F3 45 43 50 21 + // (pccc.c PCCC_RESP_PREFIX) and a zeroed sender context, whatever requestor id was sent. With the + // 0x1337 CIP originator identity every native read was BadReply. Found by Task 17 slc500. + byte[] fixedId = [0x07, 0x3D, 0xF3, 0x45, 0x43, 0x50, 0x21]; + await using var server = new FakeEipServer { ZeroReplyContext = true }; + server.Handler = req => + { + if (FakeEipServer.Command(req) != Ab.Transport.Eip.EncapCommand.SendRRData) return null; + var cip = FakeEipServer.RequestCip(req); + cip.AsSpan(6, 7).ToArray().ShouldBe(fixedId); + byte[] reply = [0xCB, 0x00, 0x00, 0x00, .. fixedId, 0x4F, 0x00, cip[15], cip[16], 0xE1, 0x10]; + return FakeEipServer.RRReply(req, reply); + }; + + var pool = new DeviceSessionPool(); + var p = new AbLegacyTagCreateParams("127.0.0.1", server.Port, "1,0", "slc500", "N7:0", TimeSpan.FromSeconds(5)); + using var runtime = new NativeAbLegacyTagRuntime(p, pool); + await runtime.InitializeAsync(TestContext.Current.CancellationToken); + await runtime.ReadAsync(TestContext.Current.CancellationToken); + + runtime.GetStatus().ShouldBe(0); + runtime.DecodeValue(AbLegacyDataType.Int, null).ShouldBe(4321); + server.HandlerErrors.ShouldBeEmpty(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj index b86a5a81..a2b220f3 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj @@ -21,6 +21,15 @@ + + + + + + + + + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs index 3e171d59..b52af00f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs @@ -100,4 +100,76 @@ public sealed class AbCipDriverPageFormSerializationTests back.Devices[0].PlcFamily.ShouldBe(AbCipPlcFamily.ControlLogix); back.Devices[1].PlcFamily.ShouldBe(AbCipPlcFamily.CompactLogix); } + + // ---- Task 19: Transport (native AB transport switch) ---- + // These drive the form's OWN load/save path (TryDeserialize -> FromOptions -> ToOptions -> Serialize), + // which uses the production JsonSerializerOptions, so a numeric enum or a dropped field shows up here. + + [Theory] + [InlineData(AbCipTransport.Libplctag)] + [InlineData(AbCipTransport.Native)] + [InlineData(null)] + public void Transport_survives_FromOptions_ToOptions(AbCipTransport? transport) + { + var original = new AbCipDriverOptions { Transport = transport }; + var back = AbCipDriverForm.FormModel.FromOptions(original).ToOptions([]); + back.Transport.ShouldBe(transport); + } + + [Fact] + public void Native_transport_serializes_as_a_string() + { + var form = new AbCipDriverForm.FormModel { Transport = AbCipTransport.Native }; + var json = AbCipDriverForm.Serialize(form.ToOptions([])); + json.ShouldContain("\"transport\":\"Native\""); + } + + [Theory] + [InlineData("Native", AbCipTransport.Native)] + [InlineData("Libplctag", AbCipTransport.Libplctag)] + public void Editing_an_unrelated_field_preserves_an_explicit_transport(string savedValue, AbCipTransport expected) + { + // The regression the review found: FromOptions/ToOptions dropped Transport, so an operator who set + // a transport was silently reverted the next time they saved ANY other edit. + var saved = $$"""{"transport":"{{savedValue}}","timeout":"00:00:02","devices":[]}"""; + var loaded = AbCipDriverForm.TryDeserialize(saved); + loaded.ShouldNotBeNull(); + loaded.Transport.ShouldBe(expected); + + var form = AbCipDriverForm.FormModel.FromOptions(loaded); + form.TimeoutSeconds = 7; + var json = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices)); + + json.ShouldContain($"\"transport\":\"{savedValue}\""); + json.ShouldContain("\"timeout\":\"00:00:07\""); + } + + [Fact] + public void A_config_without_transport_loads_as_Default_and_an_unrelated_edit_keeps_the_key_absent() + { + // Task 22 reverses Task 19's "re-emit an explicit Libplctag": that would pin every edited driver to + // a concrete transport and bypass the driver-side default (and AbLegacy's PLC-5 / LogixPccc + // exclusion). Absent loads as Default (null) and a save that edits something else still omits it. + var loaded = AbCipDriverForm.TryDeserialize("""{"probeTimeoutSeconds":5}"""); + loaded.ShouldNotBeNull(); + var form = AbCipDriverForm.FormModel.FromOptions(loaded); + form.Transport.ShouldBeNull(); + + form.TimeoutSeconds = 7; + var json = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices)); + json.ShouldNotContain("transport", Case.Insensitive); + json.ShouldContain("\"timeout\":\"00:00:07\""); + } + + [Fact] + public void Choosing_Default_over_an_explicit_transport_drops_the_key() + { + var loaded = AbCipDriverForm.TryDeserialize("""{"transport":"Libplctag"}"""); + loaded.ShouldNotBeNull(); + var form = AbCipDriverForm.FormModel.FromOptions(loaded); + form.Transport.ShouldBe(AbCipTransport.Libplctag); + + form.Transport = null; // the "Default (...)" dropdown option + AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices)).ShouldNotContain("transport", Case.Insensitive); + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs index 5c22a9a1..ec8b74d3 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs @@ -96,4 +96,95 @@ public sealed class AbLegacyDriverPageFormSerializationTests back.Devices[0].PlcFamily.ShouldBe(AbLegacyPlcFamily.Slc500); back.Devices[1].PlcFamily.ShouldBe(AbLegacyPlcFamily.MicroLogix); } + + // ---- Task 19: Transport (native AB transport switch) ---- + // These drive the form's OWN load/save path (TryDeserialize -> FromOptions -> ToOptions -> Serialize), + // which uses the production JsonSerializerOptions, so a numeric enum or a dropped field shows up here. + + [Theory] + [InlineData(AbLegacyTransport.Libplctag)] + [InlineData(AbLegacyTransport.Native)] + [InlineData(null)] + public void Transport_survives_FromOptions_ToOptions(AbLegacyTransport? transport) + { + var original = new AbLegacyDriverOptions { Transport = transport }; + var back = AbLegacyDriverForm.FormModel.FromOptions(original).ToOptions([]); + back.Transport.ShouldBe(transport); + } + + [Fact] + public void Native_transport_serializes_as_a_string() + { + var form = new AbLegacyDriverForm.FormModel { Transport = AbLegacyTransport.Native }; + var json = AbLegacyDriverForm.Serialize(form.ToOptions([])); + json.ShouldContain("\"transport\":\"Native\""); + } + + [Theory] + [InlineData("Native", AbLegacyTransport.Native)] + [InlineData("Libplctag", AbLegacyTransport.Libplctag)] + public void Editing_an_unrelated_field_preserves_an_explicit_transport(string savedValue, AbLegacyTransport expected) + { + // The regression the review found: FromOptions/ToOptions dropped Transport, so an operator who set + // a transport was silently reverted the next time they saved ANY other edit. + var saved = $$"""{"transport":"{{savedValue}}","timeout":"00:00:02","devices":[]}"""; + var loaded = AbLegacyDriverForm.TryDeserialize(saved); + loaded.ShouldNotBeNull(); + loaded.Transport.ShouldBe(expected); + + var form = AbLegacyDriverForm.FormModel.FromOptions(loaded); + form.TimeoutSeconds = 7; + var json = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices)); + + json.ShouldContain($"\"transport\":\"{savedValue}\""); + json.ShouldContain("\"timeout\":\"00:00:07\""); + } + + [Fact] + public void A_config_without_transport_loads_as_Default_and_an_unrelated_edit_keeps_the_key_absent() + { + // Task 22 reverses Task 19's "re-emit an explicit Libplctag": that would pin every edited driver to + // a concrete transport and bypass the driver-side default (Libplctag since final review I3). Absent loads as Default (null) and a save that edits something else still omits it. + var loaded = AbLegacyDriverForm.TryDeserialize("""{"probeTimeoutSeconds":5}"""); + loaded.ShouldNotBeNull(); + var form = AbLegacyDriverForm.FormModel.FromOptions(loaded); + form.Transport.ShouldBeNull(); + + form.TimeoutSeconds = 7; + var json = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices)); + json.ShouldNotContain("transport", Case.Insensitive); + json.ShouldContain("\"timeout\":\"00:00:07\""); + } + + [Fact] + public void Choosing_Default_over_an_explicit_transport_drops_the_key() + { + var loaded = AbLegacyDriverForm.TryDeserialize("""{"transport":"Libplctag"}"""); + loaded.ShouldNotBeNull(); + var form = AbLegacyDriverForm.FormModel.FromOptions(loaded); + form.Transport.ShouldBe(AbLegacyTransport.Libplctag); + + form.Transport = null; // the "Default (...)" dropdown option + AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices)).ShouldNotContain("transport", Case.Insensitive); + } + + /// + /// Final review I3: the dropdown labels must say what an absent key does (libplctag) and that Native is + /// opt-in. Source scan (no bUnit in this repo); the labels are static option text. + /// + [Fact] + public void The_transport_dropdown_labels_name_libplctag_as_the_default_and_native_as_opt_in() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) + dir = dir.Parent; + dir.ShouldNotBeNull("could not locate the repository root"); + var razor = File.ReadAllText(Path.Combine(dir!.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.AdminUI", + "Components", "Shared", "Drivers", "Forms", "AbLegacyDriverForm.razor")); + + razor.ShouldContain(""); + razor.ShouldContain(""); + razor.ShouldContain(""); + razor.ShouldNotContain("Default (Native"); + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs index 850b6553..b0e3da0a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs @@ -168,8 +168,10 @@ public sealed class DriverProbeHandshakeE2eTests // ---- AbCip : libplctag CIP session handshake (skips unless the sim fixture is up) ---- - [Fact] - public async Task AbCip_Green_AgainstSim() + [Theory] + [InlineData("Native")] + [InlineData("Libplctag")] + public async Task AbCip_Green_AgainstSim(string transport) { SkipUnless(DockerHost, AbCipPort); // Probe an explicit tag that the ab_server ControlLogix sim actually defines @@ -182,7 +184,9 @@ public sealed class DriverProbeHandshakeE2eTests // exercising the one path the comment above says it does not exercise — and reporting it as a // failure of the fixture rather than of the config shape. var result = await new AbCipDriverProbe().ProbeAsync( - $"{{\"Devices\":[{{\"HostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}]," + + // Run on BOTH transports (Task 22 review r1 I3): Native is the production default, and the + // probe's green messages are transport-neutral strings, so each row asserts the same text. + $"{{\"Transport\":\"{transport}\",\"Devices\":[{{\"HostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}]," + $"\"RawTags\":[{{\"RawPath\":\"Plant/AbCip/dev1/TestDINT\"," + $"\"TagConfig\":\"{{\\\"tagPath\\\":\\\"TestDINT\\\",\\\"dataType\\\":\\\"DInt\\\"}}\"," + $"\"WriteIdempotent\":false,\"DeviceName\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}]}}", @@ -212,12 +216,16 @@ public sealed class DriverProbeHandshakeE2eTests /// Docker/patches/0002-ab_server-answer-tag-not-found-with-cip-0x04.patch — so this test /// needs the fixture rebuilt from that patch, not merely running. /// - [Fact] - public async Task AbCip_WithNoAuthoredTags_ReportsTheControllerReachable() + [Theory] + [InlineData("Native")] + [InlineData("Libplctag")] + public async Task AbCip_WithNoAuthoredTags_ReportsTheControllerReachable(string transport) { SkipUnless(DockerHost, AbCipPort); var result = await new AbCipDriverProbe().ProbeAsync( - $"{{\"Devices\":[{{\"HostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}],\"RawTags\":[]}}", + // Both transports (Task 22 review r1 I3): native maps CIP 0x04 to AbStatus.NotFound exactly as + // libplctag's ErrorNotFound does, so the reachable classification is the same. + $"{{\"Transport\":\"{transport}\",\"Devices\":[{{\"HostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}],\"RawTags\":[]}}", Timeout, Ct); result.Ok.ShouldBeTrue($"Probe message: {result.Message}"); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/AbCipDriverDefaultsOptionsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/AbCipDriverDefaultsOptionsTests.cs new file mode 100644 index 00000000..9565cdc2 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/AbCipDriverDefaultsOptionsTests.cs @@ -0,0 +1,154 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit; +using ZB.MOM.WW.Configuration; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; +using ZB.MOM.WW.OtOpcUa.Host.Configuration; +using ZB.MOM.WW.OtOpcUa.Host.Drivers; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration; + +/// +/// Final review I1 — the node-level Drivers:AbCip:DefaultTransport: binding (appsettings and the +/// __ env-var form), precedence (explicit per-driver key > node default > compiled Native), the +/// probe agreeing with the driver factory, and fail-fast validation. Every precedence test runs a pinned +/// node (Libplctag) against the compiled default (Native), so a wiring that ignores the node value goes red. +/// +[Trait("Category", "Unit")] +[Collection(AbTransportSharedPoolCollection.Name)] // resolves the registry, which configures the shared pool +public sealed class AbCipDriverDefaultsOptionsTests +{ + private const string NoKey = """{"devices":[{"hostAddress":"ab://10.0.0.1/1,0"}],"probe":{"enabled":false}}"""; + private const string ExplicitNative = + """{"transport":"Native","devices":[{"hostAddress":"ab://10.0.0.1/1,0"}],"probe":{"enabled":false}}"""; + + private static ServiceProvider Build(IConfiguration configuration) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new StubSecretResolver()); + services.AddValidatedOptions( + configuration, AbCipDriverDefaultsOptions.SectionName); + services.AddOtOpcUaDriverFactories(); + return services.BuildServiceProvider(); + } + + private static IConfiguration Config(string? value) => + new ConfigurationBuilder() + .AddInMemoryCollection(value is null + ? [] + : new Dictionary { ["Drivers:AbCip:DefaultTransport"] = value }) + .Build(); + + private static AbCipDriver Create(ServiceProvider sp, string json) => + (AbCipDriver)sp.GetRequiredService().TryGet(DriverTypeNames.AbCip)!("drv-i1", json); + + [Fact] + public void Absent_section_defaults_to_Native() + { + using var sp = Build(Config(null)); + sp.GetRequiredService>().Value.ResolveDefaultTransport() + .ShouldBe(AbCipTransport.Native); + Create(sp, NoKey).EffectiveTransport.ShouldBe(AbCipTransport.Native); + } + + [Theory] + [InlineData("Libplctag", AbCipTransport.Libplctag)] + [InlineData("libplctag", AbCipTransport.Libplctag)] + [InlineData("NATIVE", AbCipTransport.Native)] + [InlineData("", AbCipTransport.Native)] + public void The_appsettings_key_binds(string value, AbCipTransport expected) + { + using var sp = Build(Config(value)); + sp.GetRequiredService>().Value.ResolveDefaultTransport() + .ShouldBe(expected); + } + + /// The documented env-var override form, Drivers__AbCip__DefaultTransport. + [Fact] + public void The_double_underscore_environment_variable_binds() + { + const string prefix = "OTOPCUA_I1_TEST_"; + Environment.SetEnvironmentVariable(prefix + "Drivers__AbCip__DefaultTransport", "Libplctag"); + try + { + var configuration = new ConfigurationBuilder().AddEnvironmentVariables(prefix).Build(); + using var sp = Build(configuration); + Create(sp, NoKey).EffectiveTransport.ShouldBe(AbCipTransport.Libplctag); + } + finally + { + Environment.SetEnvironmentVariable(prefix + "Drivers__AbCip__DefaultTransport", null); + } + } + + /// A pinned node moves no-key drivers; an explicit per-driver key still wins over the pin. + [Fact] + public void Node_default_applies_only_to_drivers_without_a_key() + { + using var sp = Build(Config("Libplctag")); + Create(sp, NoKey).EffectiveTransport.ShouldBe(AbCipTransport.Libplctag); + Create(sp, ExplicitNative).EffectiveTransport.ShouldBe(AbCipTransport.Native); + } + + /// The Test Connect probe resolves an absent key to the SAME node default the driver does. + [Theory] + [InlineData("Libplctag", AbCipTransport.Libplctag)] + [InlineData(null, AbCipTransport.Native)] + public void The_probe_uses_the_node_default(string? value, AbCipTransport expected) + { + using var sp = Build(Config(value)); + var probe = sp.GetServices().OfType().ShouldHaveSingleItem(); + probe.DefaultTransport.ShouldBe(expected); + Create(sp, NoKey).EffectiveTransport.ShouldBe(expected); // driver and probe agree + } + + /// The probe stays registered exactly once on a fused node (driver path + admin path). + [Fact] + public void The_factory_built_probe_is_still_deduplicated() + { + var services = new ServiceCollection(); + services.AddOtOpcUaDriverProbes(); + services.AddOtOpcUaDriverProbes(); + using var sp = services.BuildServiceProvider(); + sp.GetServices().OfType().Count().ShouldBe(1); + } + + [Theory] + [InlineData("Bogus")] + [InlineData("1")] + [InlineData("7")] + [InlineData("Native,Libplctag")] + public void A_bad_value_fails_validation_with_a_named_message(string value) + { + var result = new AbCipDriverDefaultsOptionsValidator().Validate( + null, new AbCipDriverDefaultsOptions { DefaultTransport = value }); + result.Failed.ShouldBeTrue(); + result.Failures.ShouldContain(f => f.Contains("Drivers:AbCip:DefaultTransport") && f.Contains(value)); + + using var sp = Build(Config(value)); + Should.Throw( + () => sp.GetRequiredService>().Value) + .Message.ShouldContain("Drivers:AbCip:DefaultTransport"); + } + + [Theory] + [InlineData("Native")] + [InlineData("libplctag")] + [InlineData(null)] + [InlineData(" ")] + public void A_good_or_blank_value_validates(string? value) => + new AbCipDriverDefaultsOptionsValidator() + .Validate(null, new AbCipDriverDefaultsOptions { DefaultTransport = value }) + .Succeeded.ShouldBeTrue(); + + private sealed class StubSecretResolver : ISecretResolver + { + public Task GetAsync(SecretName name, CancellationToken ct) => Task.FromResult(null); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/AbTransportSharedLoggerWiringTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/AbTransportSharedLoggerWiringTests.cs new file mode 100644 index 00000000..4e4c9585 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/AbTransportSharedLoggerWiringTests.cs @@ -0,0 +1,108 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; +using ZB.MOM.WW.OtOpcUa.Host.Drivers; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration; + +/// The shared pool is process-wide, so the tests that touch it must not run in parallel. +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class AbTransportSharedPoolCollection +{ + public const string Name = "AbTransportSharedPool"; +} + +/// +/// Final review I2: DeviceSessionPool.Shared must log through the HOST logger factory, on driver +/// nodes (via the driver registry) and on admin-only nodes (via the AbCip / AbLegacy Test Connect probes). +/// Each test first resets the pool to a null logger and asserts that baseline, so deleting the wiring +/// call turns it red (the logger would stay the reset one). +/// +[Trait("Category", "Unit")] +[Collection(AbTransportSharedPoolCollection.Name)] +public sealed class AbTransportSharedLoggerWiringTests +{ + private static RecordingLoggerFactory Reset() + { + DeviceSessionPool.ConfigureSharedLogger(NullLoggerFactory.Instance); + var factory = new RecordingLoggerFactory(); + AssertPoolLogsTo(factory, expected: false); // baseline: the wiring under test has not run yet + return factory; + } + + // Logger wraps the factory's logger, so identity cannot be compared: emit through the pool's logger + // and check whether it lands in the recording factory under the pool's category. + private static void AssertPoolLogsTo(RecordingLoggerFactory factory, bool expected) + { + var marker = Guid.NewGuid().ToString("N"); + DeviceSessionPool.Shared.Logger.LogWarning("wiring-probe {Marker}", marker); + factory.Messages(typeof(DeviceSessionPool).FullName!).Any(m => m.Contains(marker)).ShouldBe(expected); + } + + [Fact] + public void Resolving_the_driver_registry_wires_the_pool_to_the_host_logger_factory() + { + var factory = Reset(); + var services = new ServiceCollection(); + services.AddSingleton(factory); + services.AddSingleton(new StubSecretResolver()); + services.AddOtOpcUaDriverFactories(); + using var sp = services.BuildServiceProvider(); + + sp.GetRequiredService(); + + AssertPoolLogsTo(factory, expected: true); + } + + [Theory] + [InlineData(DriverTypeNames.AbCip)] + [InlineData(DriverTypeNames.AbLegacy)] + public void An_admin_only_node_wires_the_pool_through_the_AB_probes(string driverType) + { + var factory = Reset(); + var services = new ServiceCollection(); + services.AddSingleton(factory); + services.AddOtOpcUaDriverProbes(); // the admin-only path: no driver registry + using var sp = services.BuildServiceProvider(); + + sp.GetServices().Single(p => p.DriverType == driverType); + + AssertPoolLogsTo(factory, expected: true); + } + + private sealed class RecordingLoggerFactory : ILoggerFactory + { + private readonly List<(string Category, string Message)> _messages = []; + + public IReadOnlyList Messages(string category) + { + lock (_messages) return [.. _messages.Where(m => m.Category == category).Select(m => m.Message)]; + } + + public ILogger CreateLogger(string categoryName) => new Recording(this, categoryName); + public void AddProvider(ILoggerProvider provider) { } + public void Dispose() { } + + private sealed class Recording(RecordingLoggerFactory owner, string category) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + lock (owner._messages) owner._messages.Add((category, formatter(state, exception))); + } + } + } + + private sealed class StubSecretResolver : ISecretResolver + { + public Task GetAsync(SecretName name, CancellationToken ct) => Task.FromResult(null); + } +}