Merge native .NET Allen-Bradley transport (AbCip native default; AbLegacy native opt-in; real-press gated)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.csproj" />
|
||||
@@ -109,6 +110,7 @@
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj" />
|
||||
|
||||
@@ -445,6 +445,30 @@ for the enablement runbook (ApiKey handling, stop/start-together rule, tombstone
|
||||
never-`sqlite3`-a-live-WAL-DB inspection rules) and the "pair-local config cache" section of
|
||||
[`docs/Redundancy.md`](Redundancy.md) for what boot-from-cache does and does not cover.
|
||||
|
||||
### `Drivers:AbCip` (node-level AbCip defaults)
|
||||
|
||||
- **Purpose:** pin the wire transport an **AbCip driver with no `transport` key** runs on, for every such
|
||||
driver on this node, without editing driver configs (final review I1 of the native AB transport). The
|
||||
per-driver `DriverConfig` key `transport` still wins when present. The main use is an upgrade: the old
|
||||
AdminUI cannot author the per-driver key (its typed form drops unknown keys, #576), so this is the way to
|
||||
keep a fleet on libplctag across the upgrade restart. See
|
||||
[`drivers/AbCip.md` § Transport](drivers/AbCip.md#transport-transport-option).
|
||||
- **Options class:** `AbCipDriverDefaultsOptions` (`SectionName = "Drivers:AbCip"`) —
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/AbCipDriverDefaultsOptions.cs`. Bound and validated
|
||||
unconditionally in `Program.cs` (`AddValidatedOptions` + `ValidateOnStart`), because both consumers need
|
||||
it: the driver factory (driver role) and the AdminUI Test Connect probe (admin role), so the probe
|
||||
exercises the stack the driver would run.
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `DefaultTransport` | string | `Native` | `Native` or `Libplctag`, case-insensitive. Blank/absent = `Native`. **Any other value fails host start** (`AbCipDriverDefaultsOptionsValidator`, naming the key), including numeric (`1`) and flags (`Native,Libplctag`) forms. Env form: `Drivers__AbCip__DefaultTransport`. |
|
||||
|
||||
Precedence for an AbCip driver: explicit `transport` in its `DriverConfig` → this key → compiled `Native`.
|
||||
Like the per-driver key, it takes effect when a driver instance is **constructed** (process start, or a
|
||||
deploy that recreates the driver), not on `ReinitializeAsync`. The init log line reads
|
||||
`transport=Libplctag (node default)` when the pin is what selected the stack. There is **no AbLegacy
|
||||
counterpart**: an AbLegacy driver with no key always runs libplctag (Native is opt-in per driver).
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
@@ -458,6 +482,7 @@ All names are read in this repo's source via `Environment.GetEnvironmentVariable
|
||||
| `OTOPCUA_ROLES` | `EffectiveRoles.Resolve` (`ZB.MOM.WW.OtOpcUa.Cluster`) — the single role authority both `Program.cs` and `AddOtOpcUaCluster` consume | Comma-separated cluster roles for the node (`admin`, `driver`, `dev`, `cluster-{ClusterId}`), read as the **fallback** only when `Cluster:Roles` is empty. Drives the conditional wiring, the per-role appsettings overlay, and the node's Akka member roles — all from the same resolved instance. See the note under `Cluster:Roles` above. |
|
||||
| `OTOPCUA_CONFIG_CONNECTION` | `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/DesignTimeDbContextFactory.cs` (design-time / `dotnet ef` only) | Read at **design time** by `DesignTimeDbContextFactory.cs` for `dotnet ef` migrations. At **runtime** the server resolves the connection string from `ConnectionStrings:ConfigDb` (env form: `ConnectionStrings__ConfigDb`) via `configuration.GetConnectionString("ConfigDb")` in `ServiceCollectionExtensions.cs` — `OTOPCUA_CONFIG_CONNECTION` appears there only as a hint in an error message, not via `GetEnvironmentVariable`. No credential is embedded in source. |
|
||||
| `ASPNETCORE_ENVIRONMENT` | ASP.NET host builder (framework) | Selects `appsettings.{Environment}.json` (e.g. `Development`). |
|
||||
| `Drivers__AbCip__DefaultTransport` | configuration binder (`AbCipDriverDefaultsOptions`) | Env form of [`Drivers:AbCip:DefaultTransport`](#driversabcip-node-level-abcip-defaults): `Native` (default) or `Libplctag` for AbCip drivers with no `transport` key. A bad value fails host start. |
|
||||
|
||||
### Historian (`ServerHistorian__ApiKey`)
|
||||
|
||||
|
||||
+123
-1
@@ -76,12 +76,134 @@ the driver's `DriverConfig` JSON. Key fields:
|
||||
- **`Devices`** — one `AbCipDeviceOptions` per PLC (`HostAddress`, `PlcFamily`, optional `DeviceName`, per-device `AllowPacking` / `ConnectionSize` overrides).
|
||||
- **`Tags`** — pre-declared `AbCipTagDefinition` list; `Members` for UDT fan-out, `SafetyTag` for GuardLogix safety-partition tags.
|
||||
- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeTagPath`.
|
||||
- **`Transport`** — absent (default ⇒ `Native`), `Native`, or `Libplctag` (fallback); see [Transport](#transport-transport-option).
|
||||
- **Discovery** — `EnableControllerBrowse` (`@tags` walk) and `EnableDeclarationOnlyUdtGrouping` (whole-UDT read fast path).
|
||||
- **Alarms** — `EnableAlarmProjection` + `AlarmPollInterval` for the ALMD projection.
|
||||
|
||||
Full per-field descriptions live in `AbCipDriverOptions.cs`. The JSON skeleton is
|
||||
reproduced in [docs/v2/driver-specs.md §3](../v2/driver-specs.md).
|
||||
|
||||
## Transport (`transport` option)
|
||||
|
||||
The driver has two interchangeable wire transports, selected by one `DriverConfig` key:
|
||||
|
||||
| Key | Values | Default | Serialization |
|
||||
|---|---|---|---|
|
||||
| `transport` | absent \| `Native` \| `Libplctag` | absent ⇒ the node's `Drivers:AbCip:DefaultTransport` (itself default `Native`) | string, case-insensitive on read, written as `"Libplctag"` / `"Native"`; "use the default" is encoded by OMITTING the key |
|
||||
|
||||
- **`Native`** (the default since Task 22) is a managed in-process EtherNet/IP + CIP client
|
||||
(`Driver.Ab.Transport`).
|
||||
- **`Libplctag`** (fallback) drives the libplctag native library, as before.
|
||||
- An absent key means "use the default". It is resolved in one place, `AbCipTransportResolver`, which the
|
||||
driver, the Test Connect probe and the CLI all go through. **Precedence:** an explicit per-driver key →
|
||||
the node-level appsetting **`Drivers:AbCip:DefaultTransport`** (`Native` | `Libplctag`, env
|
||||
`Drivers__AbCip__DefaultTransport`; a bad value fails host start; see
|
||||
[Configuration.md](../Configuration.md#driversabcip-node-level-abcip-defaults)) → compiled `Native`. The
|
||||
Test Connect probe uses the same node value, so it probes the stack the driver would run. The CLI has no
|
||||
node config and uses compiled `Native`.
|
||||
- A bad value fails like a bad `PlcFamily`; the Test Connect probe reports "Config JSON is invalid".
|
||||
- A change takes effect when the driver instance is **constructed**: on every process start (so the upgrade
|
||||
restart moves no-key drivers to Native — see the release note) and when a deploy that changes `DriverConfig`
|
||||
does this (`DriverHostActor` respawns the child, #516). `ReinitializeAsync` alone keeps the transport
|
||||
it was constructed with.
|
||||
- AdminUI: the **Transport** dropdown in the advanced section of the AbCip driver form ("Default (node
|
||||
setting; Native unless pinned)" / "Native (managed)" / "libplctag (fallback)"). **Default omits the key on save**, so a defaulted driver stays
|
||||
defaulted; saving another field preserves an explicit value and preserves absence.
|
||||
- The driver logs `transport=<effective> (default|configured)` at Information on every init, e.g.
|
||||
`transport=Native (default)` or `transport=Libplctag (node default)`, including reconnect re-inits. AbCip
|
||||
drivers now receive the host logger (they previously ran on a null logger, so none of their warnings
|
||||
reached the node log); expect previously invisible warnings, e.g. a probe enabled with no probe tag path.
|
||||
- CLI: `--transport default|native|libplctag` (default `default`, which goes through the same resolver).
|
||||
- libplctag is confined to `Libplctag*.cs` adapters, enforced by a source-scan guard
|
||||
(`LibplctagConfinementGuardTests`), so removing it later is mechanical.
|
||||
|
||||
### Release note — native is the default (Task 22)
|
||||
|
||||
- **The native managed transport is now the default.** A driver config with no `transport` key runs
|
||||
`Native`; libplctag remains available as the fallback.
|
||||
- ⚠️ **Client-visible:** native reports **`BadTypeMismatch`** for a mis-authored same-width type (for example a
|
||||
tag authored `DINT` where the controller holds a `REAL`), where libplctag returned Good garbage. Correct the
|
||||
authored type; see [Native transport behaviour](#native-transport-behaviour). It does not degrade driver
|
||||
health or churn the connection; one Warning per tag names it.
|
||||
- **To fall back** one driver, set `"transport": "Libplctag"` in its driver config (or pick "libplctag
|
||||
(fallback)" in the AdminUI dropdown). To fall back a whole node, set `Drivers:AbCip:DefaultTransport` to
|
||||
`Libplctag` (below).
|
||||
- ⚠️ **When it takes effect: the upgrade restart itself.** Every process start constructs the drivers
|
||||
(including boot-from-cache on a driver-only node), so **every driver with no `transport` key moves to the
|
||||
node default — `Native` unless pinned — the moment the upgraded node starts**; no deploy is needed. After the
|
||||
upgrade, a per-driver transport change takes effect when a deploy recreates the driver (not on
|
||||
`ReinitializeAsync`).
|
||||
- **To stay on libplctag across the upgrade (pin before upgrading):** set the node-level key on each node
|
||||
you upgrade, in the same change that installs the new build — `appsettings.json`
|
||||
`"Drivers": { "AbCip": { "DefaultTransport": "Libplctag" } }`, or the environment variable
|
||||
`Drivers__AbCip__DefaultTransport=Libplctag` on the service. Older builds ignore the section, so it can be
|
||||
staged ahead of the binaries. Every AbCip driver with no `transport` key then starts on libplctag; the init
|
||||
log shows `transport=Libplctag (node default)`. Move to Native later per driver (explicit
|
||||
`"transport": "Native"` in the new AdminUI) or for the node (remove the key / set `Native`, then restart).
|
||||
⚠️ Do **not** rely on adding `"transport": "Libplctag"` to driver configs through the **old** AdminUI before
|
||||
upgrading: its typed AbCip form has no Transport field and drops unknown keys on save (#576), and on a fused
|
||||
`admin,driver` node the AdminUI and drivers upgrade together, so there is no window. The node key works on
|
||||
every node shape, including fused nodes and driver-only nodes booting from cache.
|
||||
- **Evidence, and its limits:** AbCip **reads** (plus browse and templates) were proven on a production
|
||||
CompactLogix 5069-L320ERS2 (**Z-35162**, read-only, 2026-10-08; see
|
||||
[gate results](../plans/2026-10-07-native-ab-transport-gate-results.md) §Real press). AbCip **writes** are
|
||||
simulator-proven only (`ab_server`).
|
||||
- **AbLegacy is different: its default stays libplctag** and Native is opt-in per driver; the native PCCC
|
||||
path has wire evidence (on `ab_server`) only for N-file reads/writes and one F8 read (see
|
||||
[AbLegacy.md §Transport](AbLegacy.md#transport-transport-option)).
|
||||
|
||||
## Native transport behaviour
|
||||
|
||||
- **One CIP connection and one request scheduler per device** (`DeviceSessionPool`), shared by reads,
|
||||
writes, browse and template reads. The Multiple Service Packet scheduler coalesces requests with one
|
||||
request in flight; large reads (listing pages, Read Template) are sent unpacked and exclusive.
|
||||
- **Forward Open throttle:** at most one attempt in flight, at least 1 s between starts, bounded by the
|
||||
open budget.
|
||||
- `ConnectionSize` / `Timeout` / `AllowPacking` are honoured per pooled connection, and the **first**
|
||||
driver/device to open a given device key wins — including a Test Connect probe on a fused `admin,driver`
|
||||
node (its connection carries the probe timeout). A later opener with different options is ignored and
|
||||
logged once as a Warning (`DeviceSessionPool`). ⚠️ The device key compares the **host string**, so the
|
||||
same controller addressed by DNS name in one driver and by IP in another gets two connections and two
|
||||
Forward Open throttles (up to two opens per second); address a controller the same way everywhere. ControlLogix, GuardLogix and (since T21) CompactLogix default to 4002, a Large
|
||||
Forward Open (0x5B), the same as libplctag. A target that refuses Large gets one regular open at
|
||||
min(size, 504) inside the same throttled attempt. An explicit `ConnectionSize` in the options or on the
|
||||
device overrides the family default.
|
||||
- **Routing:** Micro800 is connected with an empty route regardless of `UseConnectedMessaging`.
|
||||
SLC500/MicroLogix/PLC-5 over this driver are direct. `logixpccc` uses UnconnectedSend; its SLC framing is
|
||||
**unverified**.
|
||||
- ⚠️ **Type check (client-visible change).** The native path compares the type code the controller returns
|
||||
with the authored `DataType` and reports **`BadTypeMismatch`** on disagreement. libplctag never checks it,
|
||||
so a same-width mis-authored tag read as **Good garbage**: authoring a tag as `DINT` while the controller
|
||||
holds a `REAL` returns the REAL's bits as an integer with a Good status under libplctag, and
|
||||
`BadTypeMismatch` natively. That is the fix working, but a consumer will see Bad quality on tags that
|
||||
previously looked healthy: correct the authored type. Same-width signed/unsigned pairs are exempt, and
|
||||
writes are gated on a mismatch too. A mismatch is a **deterministic configuration refusal**, handled like
|
||||
an Unsupported refusal (final review I4): the tag's handle is kept (no re-create, no reconnect), driver
|
||||
health is unchanged (no `Degraded`, nothing fed to the comms-loss watchdog), and the Warning is logged
|
||||
**once per tag** per driver instance, not every poll. The same applies to the libplctag-path width check.
|
||||
- Malformed tag names put the device into backoff, as under libplctag (#570). STRING capacity includes the
|
||||
alignment pad (84, not 82) (#570).
|
||||
- **A refused operation is not a comms fault.** Any operation a runtime refuses as Unsupported (in AbCip, e.g.
|
||||
a native write whose type cannot be determined) reaches the client as `BadNotSupported`, keeps the tag
|
||||
handle, and leaves driver health unchanged; only real transport failures evict and degrade.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- ⚠️ **Native writes are verified against `ab_server` only (simulator-only), never a real controller.** The
|
||||
real-press gate (Task 21) was **read-only**: it proved native reads, browse and templates on Z-35162
|
||||
(2026-10-08) and nothing else. Results:
|
||||
[native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md).
|
||||
- **libplctag cannot be deleted** until a bench controller allows write verification.
|
||||
- Wide-struct fragmented writes (8-byte alignment) are unit-tested only, never proven on a controller (#567).
|
||||
- Native browse: listing pages are unpaced, and template reads outside an enumeration open a connection per
|
||||
call (#572).
|
||||
- Micro800, routed and `logixpccc` paths are covered by `ab_server` only. The Micro800 profile passes
|
||||
natively. The AbLegacy default excludes `logixpccc` (and PLC-5).
|
||||
- Follow-up issues for this workstream: #564 to #577.
|
||||
- Fixed along the way (pre-existing on master): `ab://host:44818/` with an empty CIP path (Micro800,
|
||||
unrouted MicroLogix) failed on libplctag with `ErrorBadData` because the adapter sent `Path=""`; it now
|
||||
sends no path.
|
||||
|
||||
## Alarm Projection
|
||||
|
||||
`IAlarmSource` is served by `AbCipAlarmProjection`, which polls each subscribed
|
||||
@@ -190,7 +312,7 @@ UDT-typed tags found in the walk.
|
||||
- **Native heap is invisible to the GC.** libplctag's native `Tag` heap does not show up in CLR allocation figures. Watch whole-process RSS, and use `ReinitializeAsync` (tears down + re-creates every device's libplctag handles) as the remediation for native-heap growth. (There is no per-driver footprint number to consult: `IDriver.GetMemoryFootprint()` was deleted as unconsumed in Gitea #525.)
|
||||
- **Handle eviction on failure** — a non-zero libplctag status or a transport exception evicts the cached tag runtime so the next read/write re-creates a fresh handle, mirroring the probe loop's recreate-on-failure behaviour.
|
||||
- **Read statuses carry the libplctag cause** — a tag the controller does not have reads `BadNodeIdUnknown` (not `BadCommunicationError`), and it does **not** open the device's connect-backoff window (the controller answered; before this, one typo throttled every sibling tag on the device). A genuine connection failure still opens it.
|
||||
- **Width check on scalar reads** — libplctag never checks the CIP type the controller returns. A scalar atomic read whose buffer is not the authored type's width (e.g. a REAL authored as INT — the legacy Kepware export does exactly this for some press tags) reads `BadTypeMismatch` instead of Good garbage. Same-width mistakes (DINT vs REAL) are still undetectable.
|
||||
- **Width check on scalar reads** — libplctag never checks the CIP type the controller returns. A scalar atomic read whose buffer is not the authored type's width (e.g. a REAL authored as INT — the legacy Kepware export does exactly this for some press tags) reads `BadTypeMismatch` instead of Good garbage. Same-width mistakes (DINT vs REAL) are undetectable under libplctag; the `Native` transport reports them as `BadTypeMismatch` (see [Native transport behaviour](#native-transport-behaviour)).
|
||||
- **Module-defined I/O tags** — `Local:1:I.Data`, `ABB_OmniCore:I.ConnectionFaulted` and similar colon-bearing base names are valid tag paths (controller scope only).
|
||||
- **User string arrays** — `STR0016[N]` and similar arrays stride by the actual element size (read buffer ÷ count); libplctag.NET 1.5.2 leaves `ElementSize` unset after a read.
|
||||
- **Declaration-only UDT grouping is a footgun unless verified** — only enable `EnableDeclarationOnlyUdtGrouping` when every UDT's member declaration order has been hand-verified against the controller's compiled layout.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -93,10 +93,91 @@ binds from the driver's `DriverConfig` JSON:
|
||||
- **`Devices`** — one `AbLegacyDeviceOptions` per PLC (`HostAddress`, `PlcFamily`, optional `DeviceName`).
|
||||
- **`Tags`** — pre-declared `AbLegacyTagDefinition` list (`Name`, `DeviceHostAddress`, `Address`, `DataType`, `Writable`, `WriteIdempotent`).
|
||||
- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeAddress`.
|
||||
- **`Transport`** — absent (default ⇒ `Libplctag`), `Native` (opt-in), or `Libplctag`; see [Transport](#transport-transport-option).
|
||||
|
||||
Full per-field descriptions live in the contracts assembly. The JSON skeleton is
|
||||
reproduced in [docs/v2/driver-specs.md §4](../v2/driver-specs.md).
|
||||
|
||||
## Transport (`transport` option)
|
||||
|
||||
Same switch as AB CIP: `transport` = absent \| `Native` \| `Libplctag`, a string (case-insensitive on read,
|
||||
written as `"Libplctag"` / `"Native"`; "use the default" is encoded by omitting the key). `Native` is a managed
|
||||
PCCC-over-EtherNet/IP client (`Driver.Ab.Transport`).
|
||||
|
||||
**Default resolution** (one place, `AbLegacyTransportResolver`, used by the driver, the Test Connect probe and
|
||||
the CLI): an absent key resolves to **`Libplctag` for every PLC family** — unchanged from before the native
|
||||
transport existed. **`Native` is opt-in** (final review I3: on a wire it is proven only for N-file reads and
|
||||
writes and one F8 read — see [Wire evidence](#wire-evidence-native-pccc)). An explicit value always wins:
|
||||
explicit `Native` with a PLC-5 / `LogixPccc` device is allowed (an operator choice) and logs one Warning per
|
||||
driver instance naming the limitation. (AbCip's node-level `Drivers:AbCip:DefaultTransport` has no AbLegacy
|
||||
counterpart — the AbLegacy default is libplctag and is not node-configurable.)
|
||||
|
||||
A change takes effect when the driver instance is constructed: on a deploy that changes `DriverConfig`
|
||||
(which recreates it) **and on every process start**, including the restart that installs this build (see the
|
||||
release note). The
|
||||
AdminUI form's **Transport** dropdown offers "Default (libplctag)" (omits the key on save), "Native (managed,
|
||||
opt-in)" and "libplctag". The driver logs `transport=<effective> (default|configured)` on every init, e.g.
|
||||
`transport=Libplctag (default)`. CLI: `--transport default|native|libplctag` (default `default` = libplctag for
|
||||
every `--plc-type`). See
|
||||
[AbCip.md §Transport](AbCip.md#transport-transport-option) for the shared behaviour (one pooled connection
|
||||
per device, Forward Open throttle, routing).
|
||||
|
||||
Native routing: SLC500/MicroLogix/PLC-5 are direct; `logixpccc` uses UnconnectedSend and its SLC framing is
|
||||
**unverified**.
|
||||
|
||||
### Release note — native PCCC transport, opt-in (final review I3)
|
||||
|
||||
- **The default is unchanged: libplctag.** A driver config with no `transport` key runs `Libplctag`, for
|
||||
every PLC family, exactly as before this release. The upgrade restart moves nothing. (An interim build on
|
||||
this branch defaulted SLC 500 / MicroLogix to Native; that was reverted before merge.)
|
||||
- **`"transport": "Native"` opts a driver into the managed PCCC-over-EtherNet/IP client** (`Driver.Ab.Transport`).
|
||||
It takes effect when the driver is constructed — a deploy that changes `DriverConfig`, or a process start —
|
||||
not on `ReinitializeAsync`. The AdminUI **Transport** dropdown authors it; remove the key (or pick
|
||||
"Default (libplctag)") to go back.
|
||||
- **Known limits of Native** (why it is opt-in):
|
||||
- ⚠️ **Wire evidence is narrow and simulator-only** — see [Wire evidence](#wire-evidence-native-pccc). Only
|
||||
N-file reads and writes and one F8 read have touched a wire (`ab_server`). The **default probe address
|
||||
`S:0`** and the **B, T, C, R, S, L, ST, A, O and I file types** — and the T/C/R sub-element offsets
|
||||
(`.PRE`/`.ACC`/`.LEN`/`.POS`) — are encoded from the PCCC file-type table and are **unverified natively**.
|
||||
No real SLC, MicroLogix or PLC-5 has been exercised. Opt in per driver after checking it against the real
|
||||
controller.
|
||||
- ⚠️ **Status-bit writes are refused** — `T4:0.DN`, `T4:0.TT`, `T4:0.EN`, `C5:0.CU`, `C5:0.DN`, `R6:0.EN`
|
||||
and the rest return **`BadNotSupported`**; nothing is written to the PLC; the handle is kept and health is
|
||||
unchanged. libplctag performs a masked bit write here; a native read-modify-write of the control word would
|
||||
race the PLC updating TT/DN. Reads of status bits are unaffected.
|
||||
- **PLC-5 native is N-file reads only**; every other PLC-5 operation is refused with `BadNotSupported`.
|
||||
`LogixPccc` framing is unverified. Explicit Native on either logs a Warning at init.
|
||||
- **ST (string) byte order is unverified** (no capture); string values may be byte-swapped.
|
||||
- Unlike AbCip, the native AbLegacy runtime has **no element-type check**, so a mis-authored same-width
|
||||
type reads as reinterpreted data (as on libplctag), not `BadTypeMismatch`.
|
||||
|
||||
### Wire evidence (native PCCC)
|
||||
|
||||
Everything below is against the `ab_server` simulator; no real PCCC controller has been exercised.
|
||||
|
||||
| What | Evidence |
|
||||
|---|---|
|
||||
| N-file read (`N7:0`) | libplctag captures `slc500-read-n7`, `micrologix-read-n7`, `plc5-read-n7` (codec byte parity) + native `AbLegacyReadSmokeTests` read on all three profiles |
|
||||
| N-file write (`N7:5`) | capture `slc500-write-n7` + native `AbLegacyReadSmokeTests` write/read round trip (SLC 500) |
|
||||
| F-file read (`F8`) | one capture, `slc500-read-f8` (codec parity only; no native F write on a wire) |
|
||||
| S (incl. the default probe `S:0`), B, T, C, R, L, ST, A, O, I | **none** — encoded from the PCCC file-type table |
|
||||
| T/C/R sub-elements (`.PRE`/`.ACC`/`.LEN`/`.POS`), 6-byte T/C/R element layout | **none** |
|
||||
|
||||
### Native known gaps
|
||||
|
||||
- ⚠️ **Simulator-only, and narrow.** Only N-file reads/writes and one F8 read are on a wire (`ab_server`); see
|
||||
[Wire evidence](#wire-evidence-native-pccc). No real SLC/MicroLogix/PLC-5 has been exercised; the real-press
|
||||
gate (Task 21) was read-only and AbCip-only.
|
||||
- **PLC-5 native is N-file reads only.** Every other file type and all PLC-5 writes are refused before the
|
||||
wire and reach the client as `BadNotSupported` (handle kept, health unchanged; #571 closed). Native is opt-in
|
||||
for every family; an explicit Native on PLC-5 / `logixpccc` logs a Warning at init.
|
||||
- Status-bit sub-element writes (e.g. `T4:0.DN`, `C5:0.DN`) are Unsupported natively (they would clobber the
|
||||
control word); nothing goes on the wire. Sub-element arrays are Unsupported natively. L-file bit indexes
|
||||
above 15 are allowed.
|
||||
- **ST (string) file layout is uncaptured**, so the string byte order is unverified natively.
|
||||
- Capture recipe for golden frames: `scripts/ab-capture/capture.sh` (see
|
||||
[AbLegacy-Test-Fixture.md](AbLegacy-Test-Fixture.md)). Follow-up issues: #564 to #577.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` cover the driver, the PCCC address parser, and the host-address parser via fake tag runtimes.
|
||||
@@ -109,14 +190,16 @@ An AB Legacy tag becomes a **1-D OPC UA array node** when its `TagConfig` JSON c
|
||||
`"isArray": true` and `"arrayLength": N` (N ≥ 1, capped at 256). The canonical rule:
|
||||
`isArray: true` + `arrayLength >= 1` → array; `isArray: false` (any length) → scalar.
|
||||
|
||||
**Read mechanism** — the driver issues a **PCCC multi-element file read** via libplctag.
|
||||
The tag's file address (e.g. `N7:0`) is extended by `N` element count so libplctag fetches
|
||||
**Read mechanism** — the driver issues a **PCCC multi-element file read** (libplctag by default; the opt-in
|
||||
native transport issues the same typed read, chunked under the PCCC payload limit).
|
||||
The tag's file address (e.g. `N7:0`) is extended by `N` element count so the transport fetches
|
||||
`N` consecutive PCCC file elements in one EIP/PCCC request. The response is decoded into
|
||||
N values of the declared `AbLegacyDataType`. Maximum element count is 256 (PCCC payload
|
||||
limit).
|
||||
|
||||
This closes the previously-noted limitation ("single-element addressing today") for the
|
||||
array read path. Array *writes* remain a follow-up.
|
||||
array read path. The opt-in native transport also writes multi-element arrays (chunked); on libplctag
|
||||
array *writes* remain a follow-up.
|
||||
|
||||
**Unit test coverage** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` covers
|
||||
the multi-element read path via the fake tag runtime. The AB Legacy integration fixture is
|
||||
@@ -124,7 +207,7 @@ down during normal dev.
|
||||
|
||||
**Live-verify** — integration-fixture-gated (requires the AB Legacy Docker fixture).
|
||||
|
||||
**Deferrals** — array *writes*, multi-dimensional arrays, per-element historization.
|
||||
**Deferrals** — array *writes* on libplctag (the opt-in native transport writes them), multi-dimensional arrays, per-element historization.
|
||||
`arrayLength > 256` is rejected at init with an explicit error (PCCC payload cap).
|
||||
|
||||
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
|
||||
|
||||
@@ -33,6 +33,29 @@ quirk. UDT / alarm / quirk behavior is verified only by unit tests with
|
||||
- **Endpoint override**: `AB_SERVER_ENDPOINT=host:port` points the
|
||||
fixture at a real PLC instead of the local container.
|
||||
|
||||
## Per-transport suites and capture recipe
|
||||
|
||||
Every integration test in `AbCip.IntegrationTests` now runs **once per transport** (`TransportMatrix`:
|
||||
`Libplctag` and `Native`). Run one compose profile at a time and name it with `AB_SERVER_COMPOSE_PROFILE`
|
||||
(all profiles bind `:44818`, so set it to the running profile). On the 2026-10-07 run native passed everything
|
||||
libplctag passed on all seven profiles; on `micro800` libplctag failed on the empty-path defect described in
|
||||
[AbCip.md](AbCip.md#known-gaps) and native passed. That defect is now fixed: a re-run of the `micro800`
|
||||
smoke test passes on both transports. Evidence:
|
||||
[native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md).
|
||||
|
||||
- Write-capable tests (the Logix Emulate ALMD test) require `AB_SERVER_ALLOW_WRITES=1` and refuse while
|
||||
`AB_DIFF_ENDPOINT` is set.
|
||||
- **Differential harness** (`AbCip.IntegrationTests/Differential`, `LiveIntegration`) compares libplctag and
|
||||
native on browse, template and read parity against one endpoint. It is read-only by construction, aborts on
|
||||
the first connection failure, never retries, and caps the native phase at one Forward Open. Env:
|
||||
`AB_DIFF_ENDPOINT`, `AB_DIFF_PATH`, `AB_DIFF_FAMILY`, `AB_DIFF_TAGS`, `AB_DIFF_MAX_TAGS` (at most 500),
|
||||
`AB_DIFF_MAX_TEMPLATES`, `AB_DIFF_PACE_MS` (default 20), `AB_DIFF_COUNT_OPENS`. Always run with
|
||||
`--filter "FullyQualifiedName~Differential"` and `AB_SERVER_ENDPOINT=127.0.0.1:1`.
|
||||
- ab_server serves no UDTs and refuses 1-byte writes, so template parity is vacuous there and BOOL/SINT
|
||||
parity is weak evidence. Native writes remain simulator-only.
|
||||
- **Golden-frame capture:** `scripts/ab-capture/capture.sh` (capture client in `scripts/ab-capture/client`);
|
||||
fixtures live in `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/` with a README.
|
||||
|
||||
## What it actually covers
|
||||
|
||||
- Read path: driver → libplctag → CIP-over-EtherNet/IP → simulator → back.
|
||||
@@ -93,14 +116,14 @@ Only the `_S`-suffix naming classifier (PR 12, `SecurityClassification.ViewOnly`
|
||||
forced on safety tags) runs. Actual safety-partition write rejection — what
|
||||
happens when a non-safety write lands on a real `1756-L8xS` — is not exercised.
|
||||
|
||||
### 5. CompactLogix narrow ConnectionSize cap
|
||||
### 5. CompactLogix connection size and the regular-open fallback
|
||||
|
||||
CompactLogix profile `Notes`: *"ab_server lacks the narrower limit itself."*
|
||||
|
||||
Driver-side `AbCipPlcFamilyProfile` caps `ConnectionSize` at the CompactLogix
|
||||
value per PR 10, but `ab_server` accepts whatever the client asks for — the
|
||||
cap's correctness is trusted from its unit test, never stressed against a
|
||||
simulator that rejects oversized requests.
|
||||
Since T21 the CompactLogix profile defaults to a **Large** Forward Open (4002), matching what libplctag
|
||||
opens for `plc=compactlogix`. The old 504 default was cosmetic under libplctag, which never received it,
|
||||
and cost the native transport ~3× the round trips per poll. A pre-v20 CompactLogix (1769-L3x) or a bridge that refuses
|
||||
the Large open is handled by `CipConnection`'s fallback (a regular open at min(size, 504) in the same
|
||||
throttled attempt). That fallback is pinned by unit tests against a fake target only: `ab_server` accepts
|
||||
any size, so a refusing controller is never exercised here.
|
||||
|
||||
### 6. BOOL-within-DINT read-modify-write (#181)
|
||||
|
||||
@@ -145,7 +168,7 @@ skip cleanly + the ab_server Docker fixture runs as usual.
|
||||
| `@tags` Symbol Object walk with `Program:` scope | partial | **yes** | yes |
|
||||
| Add-On Instructions | no | **yes** | yes |
|
||||
| GuardLogix safety-partition write rejection | no | **yes** (Emulate 5580) | yes |
|
||||
| CompactLogix narrow ConnectionSize enforcement | no | **yes** (5370 firmware) | yes |
|
||||
| CompactLogix Large-open refusal → regular-open fallback | no | **yes** (5370 firmware) | yes |
|
||||
| EtherNet/IP embedded-switch behaviour | no | no | yes |
|
||||
| Redundant chassis failover (1756-RM) | no | no | yes |
|
||||
| Motion control timing | no | no | yes |
|
||||
@@ -185,7 +208,7 @@ project is authored.
|
||||
| "Do ALMD alarms raise + clear?" | no | yes | **yes** | yes |
|
||||
| "Is Micro800 unconnected-only enforced wire-side?" | partial (--plc=Micro800 boots, but wire rejection untested) | partial | yes | yes (required) |
|
||||
| "Does GuardLogix reject non-safety writes on safety tags?" | no | no | yes (Emulate 5580) | yes |
|
||||
| "Does CompactLogix refuse oversized ConnectionSize?" | no | partial | yes (5370 firmware) | yes |
|
||||
| "Does CompactLogix refuse a Large Forward Open?" | no | partial | yes (5370 firmware) | yes |
|
||||
| "Does BOOL-in-DINT RMW race against concurrent writers?" | no | yes | partial | yes (stress) |
|
||||
| "Does EtherNet/IP embedded-switch behave correctly?" | no | no | no | yes (required) |
|
||||
| "Does redundant-chassis failover work?" | no | no | no | yes (required) |
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
# Native .NET transport for AbCip + AbLegacy (replace libplctag) — design
|
||||
|
||||
**Date:** 2026-10-07 · **Branch:** `feat/native-ab-transport` · **Status:** design, awaiting review
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Replace the `libplctag` NuGet (managed wrapper + native `plctag` binary, pinned `1.5.2`) under the
|
||||
AbCip and AbLegacy drivers with a fully managed EtherNet/IP + CIP + PCCC client owned by this repo.
|
||||
libplctag is kept, behind a switch, as a fallback until the native path is proven.
|
||||
|
||||
### Why (all four apply)
|
||||
|
||||
1. **Native-binary pain** — RID-specific packaging, container/ARM images, an unmanaged dependency in
|
||||
an otherwise all-.NET 10 server.
|
||||
2. **Correctness and control** — wrapper defects we cannot fix: `ElementSize` returns `null` after a
|
||||
read (`68cf3ae8`), there is no `ConnectionSize` property (the value is plumbed and dropped),
|
||||
status codes are opaque, and libplctag never checks the CIP type the controller returns (`e3d6b76e`
|
||||
could only add a width check).
|
||||
3. **Performance** — one native tag handle per `(device, tag)`; no control over request packing or
|
||||
scheduling.
|
||||
4. **Policy** — no native dependencies; libplctag's LGPL/MPL licensing.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- No change to the driver surface above the transport seams: the address space, tag-config JSON,
|
||||
the poll engine, the RMW serialization, the backoff, and the decoders all stay.
|
||||
- No new PLC families. No CIP Class-1 (implicit I/O) connections. No Omron (`omron-njnx` stays an
|
||||
unmapped libplctag value only while the fallback exists).
|
||||
- Whole-UDT **writes** stay unsupported, as they are today.
|
||||
|
||||
## 2. Decisions (all taken at the recommended option)
|
||||
|
||||
| # | Question | Decision |
|
||||
|---|---|---|
|
||||
| D1 | Build or buy | **Build** an in-repo managed stack. Existing managed CIP libraries fail the licensing/control goals. |
|
||||
| D2 | Cutover | **Side-by-side behind a per-driver `Transport` switch** (`Native` \| `Libplctag`), the same dark-switch discipline as `MeshTransport` / `ConfigSource`. |
|
||||
| D3 | Proof standard | **Flip per evidence tier** (§8). Native becomes default for AbCip once reads/browse/templates pass the real presses read-only; AbCip writes and all of AbLegacy flip on simulator proof, recorded as a known gap. libplctag stays as the fallback until a bench controller exists. |
|
||||
| D4 | Where the stack lives | A new shared project, **`ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport`**, referenced by both drivers (precedent: `Mqtt.Transport`, `OpcUaClient.Session`). Pure protocol: no OtOpcUa, no OPC UA, no logging-framework dependency beyond `Microsoft.Extensions.Logging.Abstractions`. |
|
||||
| D5 | Connection model | **One CIP connection per device** (`host:port` + CIP path), shared by every tag runtime on that device. libplctag already shares internally; the native stack makes it explicit. |
|
||||
| D6 | Request packing | **Transparent coalescing inside the connection** into Multiple Service Packet (0x0A) requests, within the negotiated connection size, when `AllowPacking`. The `IAbCipTagRuntime` interface does not change. |
|
||||
| D7 | Status neutrality | A **driver-owned `AbStatus` enum + `AbTransportException`** replace libplctag's `Status` / `LibPlcTagException` in the mappers, probes and `AbCipDriver`. The libplctag adapter translates into them. |
|
||||
| D8 | Decoder reuse | The native enumerator and template reader **emit the same byte layout libplctag's `@tags` / `@udt/{id}` pseudo-tags return**, so `CipSymbolObjectDecoder` / `CipTemplateObjectDecoder` and their real-capture tests are shared unchanged, and the two transports can be diffed byte for byte. |
|
||||
| D9 | Primary correctness evidence | **Differential testing**: the same reads through both transports against the same target (`ab_server`, then the real presses read-only), compared on raw buffer and decoded value. |
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
```
|
||||
AbCipDriver / AbLegacyDriver (unchanged except factory selection + AbStatus)
|
||||
│ IAbCipTagFactory / IAbCipTagEnumeratorFactory / IAbCipTemplateReaderFactory / IAbLegacyTagFactory
|
||||
├── Libplctag* adapters (existing; now translate Status → AbStatus) ← fallback
|
||||
└── Native* adapters (new, in each driver project)
|
||||
│
|
||||
▼
|
||||
ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport (new, pure managed)
|
||||
Eip/ EncapsulationClient — TCP framing, RegisterSession/UnRegisterSession,
|
||||
ListIdentity, SendRRData (UCMM), SendUnitData (connected)
|
||||
Cip/ CipPath (port segments from "1,0", ANSI symbolic 0x91, member/element
|
||||
segments 0x28/0x29/0x2A), CipService, CipGeneralStatus + extended status
|
||||
Cip/ ConnectionManager — Forward Open (0x54) / Large Forward Open (0x5B) /
|
||||
Forward Close (0x4E), sequence counts, connection-size negotiation,
|
||||
UCMM-only mode (Micro800)
|
||||
Cip/ RequestScheduler — per-connection queue, MSP coalescing, in-flight bound,
|
||||
per-request timeout + cancellation
|
||||
Logix/ Read Tag (0x4C) / Read Tag Fragmented (0x52) / Write Tag (0x4D) /
|
||||
Write Tag Fragmented (0x53); Logix type codes incl. struct handle (0xA0)
|
||||
Logix/ SymbolLister (Get Instance Attribute List 0x55 on class 0x6B, paged)
|
||||
Logix/ TemplateReader (Get Attribute List 0x03 + Read 0x4C on class 0x6C, chunked)
|
||||
Pccc/ Execute PCCC (0x4B, class 0x67) with requestor id; SLC/MicroLogix protected
|
||||
typed logical read/write (0x0F/0xA2, 0x0F/0xAA); PLC-5 typed read/write
|
||||
(0x0F/0x68, 0x0F/0x67); PCCC STS/EXT STS
|
||||
DeviceSessionPool — one session+connection per device key, ref-counted
|
||||
```
|
||||
|
||||
### 3.1 Units and their contracts
|
||||
|
||||
- **`EncapsulationClient`**: owns one TCP socket. It sends and receives 24-byte-header encapsulation
|
||||
frames and correlates replies by sender context. It knows nothing about CIP. Testable against canned
|
||||
byte streams.
|
||||
- **`CipPath`**: a pure encoder. It turns `"1,0"` (and `"1,0,2,10.0.0.5"` style routes) into port
|
||||
segments, and turns a Logix tag name such as `Program:P.Arr[3].Member.5` into symbolic, element and
|
||||
member segments. `AbCipTagPath` already parses names; `CipPath` encodes them. Golden-byte unit tests.
|
||||
- **`ConnectionManager`**: opens and closes the connected session for a device.
|
||||
- **Connection size.** It honours `ConnectionSize` (the field libplctag drops today). Above 511
|
||||
bytes it uses Large Forward Open. If the target rejects a large open (status 0x01, extended 0x0109
|
||||
or 0x0111), it falls back to a regular Forward Open at the size the target advertises.
|
||||
- **Micro800** (`SupportsConnectedMessaging = false`) runs UCMM-only: no Forward Open.
|
||||
- **`RequestScheduler`**: one queue per connection.
|
||||
- **Coalescing.** Requests that arrive within one scheduling turn are packed into an MSP until the
|
||||
next service would overflow the connection size. A reply is fanned back to each waiter with its own
|
||||
per-service status.
|
||||
- **Oversize requests.** A request too large for one frame goes through the fragmented service loop.
|
||||
- **Bounds.** At most one in-flight connected request per connection by default (`MaxInFlight`,
|
||||
default 1), the conservative choice for a production press.
|
||||
- **`DeviceSessionPool`**: keyed by `(host, port, cipPath, familyAttribute)`. A runtime acquires a
|
||||
lease on first `InitializeAsync` and releases it on `Dispose`. The last release sends Forward Close
|
||||
and UnRegisterSession.
|
||||
|
||||
### 3.2 Native adapters (in the driver projects)
|
||||
|
||||
- **`NativeAbCipTagRuntime : IAbCipTagRuntime`**
|
||||
- **Reads.** `InitializeAsync` acquires the device lease and resolves the encoded path once.
|
||||
`ReadAsync` issues Read Tag with `ElementCount`. The reply's **CIP type code + struct handle** is
|
||||
kept with the buffer.
|
||||
- **Decoding.** `Decode*` reads the managed buffer, little-endian. Strings use the Logix
|
||||
`LEN(u32) + DATA[n]` layout with the stride taken from the reply. BOOL arrays are 32-bit packed.
|
||||
- **Size and status.** `GetSize()` is the real reply length, so the stride fallback in
|
||||
`ResolveArrayStride` stops being necessary. `GetStatus()` returns `AbStatus`.
|
||||
- **Writes.** `WriteAsync` issues Write Tag with the type taken from the last read, or from the
|
||||
authored type for atomics. A struct-typed write (STRING) needs the struct handle, so a write with
|
||||
no prior read performs one read first.
|
||||
- **`NativeAbCipTagEnumerator`** reads controller-scope and `Program:X` listings through
|
||||
`SymbolLister`, and emits the concatenated entry bytes in the `@tags` layout.
|
||||
- **`NativeAbCipTemplateReader`** emits the 14-byte header that libplctag synthesizes, then the raw
|
||||
template bytes (D8).
|
||||
- **`NativeAbLegacyTagRuntime : IAbLegacyTagRuntime`**
|
||||
- **Addressing.** Encodes `AbLegacyAddress` (file type, file number, element, sub-element) into PCCC
|
||||
typed-logical addressing, or PLC-5 system addressing for `plc5`.
|
||||
- **Arrays.** Multi-element reads use the element count.
|
||||
- **Bits.** Bit-in-word writes stay at the driver level (`WriteBitInWordAsync`), as today.
|
||||
|
||||
### 3.3 The switch
|
||||
|
||||
- `AbCipDriverOptions.Transport` and `AbLegacyDriverOptions.Transport` take values
|
||||
`Native | Libplctag`, are serialized as **strings** (`JsonStringEnumConverter`, per the
|
||||
driver-enum-serialization trap), and are absent ⇒ the current default.
|
||||
- `Ab*DriverFactoryExtensions.CreateInstance` picks the factory set from the option. Test-injected
|
||||
factories still win.
|
||||
- The **probe** (`Ab*DriverProbe`, i.e. Test Connect) honours the same option. A probe that tests a
|
||||
different transport than the driver runs would be a lie.
|
||||
- **AdminUI**: a `Transport` dropdown in the AbCip and AbLegacy driver-config modals under the `/raw`
|
||||
tree. It needs live `/run` verification on docker-dev, since there is no bUnit.
|
||||
- Default sequence: `Libplctag` at merge, flipped per §8, and the fallback stays selectable.
|
||||
|
||||
## 4. Data flow (a poll read)
|
||||
|
||||
1. The driver poll calls `runtime.ReadAsync(ct)` on N runtimes for one device. The concurrency
|
||||
window is unchanged.
|
||||
2. Each runtime enqueues a Read Tag request on the device's `RequestScheduler`.
|
||||
3. The scheduler packs as many as fit into one MSP, sends it via SendUnitData on the shared
|
||||
connection, awaits the reply, splits it by service, and completes each waiter with
|
||||
`(status, typeCode, structHandle, payload)`.
|
||||
4. The runtime stores the payload. The driver calls `Decode*` exactly as it does today.
|
||||
|
||||
The result: N round-trips through N libplctag handles become ⌈N / fit⌉ round-trips through one
|
||||
connection. That is the performance win, with no driver change.
|
||||
|
||||
## 5. Error handling and status
|
||||
|
||||
- **`AbStatus`** (driver-owned) carries the values the mappers and probes use today: `Ok`,
|
||||
`Pending`, `Timeout`, `NotFound`, `NotAllowed`, `OutOfBounds`, `TooLarge`, `TooSmall`,
|
||||
`Unsupported`, `BadConnection`, `BadReply`, `Partial`, `Abort`, plus a new `TypeMismatch`.
|
||||
- **CIP general status mapping (native):**
|
||||
- `0x00` ⇒ `Ok`
|
||||
- `0x04` / `0x05` (path segment / destination unknown) ⇒ `NotFound`
|
||||
- `0x06` ⇒ `Partial` (continue fragmenting)
|
||||
- `0x08` ⇒ `Unsupported`
|
||||
- `0x0F` ⇒ `NotAllowed`
|
||||
- `0x13` ⇒ `TooSmall`
|
||||
- `0x15` ⇒ `TooLarge`
|
||||
- `0xFF` ⇒ by extended status (`0x2105` ⇒ `OutOfBounds`, `0x2107` ⇒ `TypeMismatch`)
|
||||
- socket, encapsulation or timeout faults ⇒ `BadConnection` / `Timeout`
|
||||
- **PCCC mapping.** STS/EXT STS map the same way: address-does-not-exist ⇒ `NotFound`, illegal
|
||||
command/format ⇒ `Unsupported`, and so on.
|
||||
- **`AbTransportException(AbStatus, string detail)`** is thrown by both transports.
|
||||
`AbCipDriver.cs:1188`'s "the controller answered, so don't throttle the device" check
|
||||
(`IsReachableStatus`) keys on it. **That preserves 05/STAB-8 semantics.**
|
||||
- **Type check, native only.** The reply's CIP type code is compared with the authored type.
|
||||
- A mismatch ⇒ `BadTypeMismatch`. This now also catches **same-width** mistakes (DINT authored,
|
||||
REAL returned) that `e3d6b76e` documents as undetectable.
|
||||
- ⚠️ **This is client-visible.** Tags authored wrong from the legacy Kepware export that read Good
|
||||
garbage under libplctag will read Bad under native. That is the fix working, but it must be
|
||||
in the release note and in `docs/drivers/AbCip.md`.
|
||||
- **Press safety.**
|
||||
- **Reconnect.** A dropped connection is re-opened through the driver's existing `device.Backoff`,
|
||||
never in a tight loop.
|
||||
- **One open at a time.** `ConnectionManager` enforces at most one Forward Open in flight per
|
||||
device, plus a minimum interval between opens (default 1 s).
|
||||
- **Pinned by a test.** A regression test asserts a bounded Forward Open rate against a refusing
|
||||
target. ignitionoee recorded **~15 Forward Opens/s** at a press from a misconfigured client.
|
||||
- **Close.** Forward Close on dispose. The connection timeout multiplier and RPI are set so a dead
|
||||
client's connection expires on the PLC within seconds, not minutes.
|
||||
|
||||
## 6. Packaging
|
||||
|
||||
- New project `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/` and its test project
|
||||
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/`, both added to
|
||||
`ZB.MOM.WW.OtOpcUa.slnx`.
|
||||
- While the fallback exists, both drivers keep the `libplctag` package. Libplctag code is confined
|
||||
to the `Libplctag*.cs` adapters. After phase 2 no other file has `using libplctag`, and a
|
||||
source-scan guard test enforces that.
|
||||
- On deletion (phase 6) the package, its `Directory.Packages.props` pin, the adapters and the
|
||||
`Libplctag*` create-param members (`LibplctagGateway`; `LibplctagPlcAttribute` is renamed
|
||||
`PlcAttribute`) go.
|
||||
|
||||
## 7. Testing
|
||||
|
||||
1. **Golden-frame unit tests** (`Ab.Transport.Tests`).
|
||||
- **Encode** every frame type: RegisterSession, Forward Open / Large / Close, MSP, Read / Write
|
||||
(Fragmented), 0x55 listing, template attribute reads, Execute PCCC.
|
||||
- **Decode** every reply type, including error replies.
|
||||
- **Fixtures** are byte captures taken once from libplctag talking to `ab_server` (pcap, stored as
|
||||
hex), plus the **existing real-press captures** in `CipSymbolObjectWireCaptureTests` and
|
||||
`CipTemplateObjectWireCaptureTests`.
|
||||
- ⚠️ A capture from libplctag ⇄ `ab_server` proves wire parity with libplctag, not correctness. The
|
||||
real-press captures are the independent reference (the #468 lesson: verify a fixture with the
|
||||
reference client).
|
||||
2. **Scheduler and connection tests.** These run against an in-process fake encapsulation endpoint.
|
||||
They cover:
|
||||
- MSP packing boundaries at connection size −1, exact and +1
|
||||
- per-service error fan-out
|
||||
- cancellation mid-MSP
|
||||
- the Forward Open rate bound
|
||||
- large-open fallback
|
||||
- lease ref-counting and Forward Close on last release
|
||||
3. **Integration suites run on both transports.** The existing `AbCip.IntegrationTests` and
|
||||
`AbLegacy.IntegrationTests` against `ab_server` (controllogix / compactlogix / micro800 / guardlogix;
|
||||
slc500 / micrologix / plc5) are parameterized over `Transport`. A native run must be green on every
|
||||
test the libplctag run is green on.
|
||||
4. **Differential harness.** A `LiveIntegration`-category test, env-gated like the HistorianGateway
|
||||
live suite and skipped when the env vars are absent. It reads the same tag set through both
|
||||
transports and asserts equal raw buffers and equal decoded values:
|
||||
- against `ab_server`;
|
||||
- against a real press, **read-only**. It runs browse (`@tags` + every program listing), every
|
||||
template, and a sample of scalar, array, STRING and UDT-member reads.
|
||||
5. **AdminUI `/run`.** Author `Transport: Native` on a docker-dev AbCip driver pointed at the
|
||||
`ab_server` fixture, deploy, read, then flip it back.
|
||||
|
||||
## 8. Phased delivery and gates
|
||||
|
||||
| Phase | Delivers | Gate |
|
||||
|---|---|---|
|
||||
| **P0 Seam** | `AbStatus` + `AbTransportException`; mappers, probes, `AbCipDriver` and `AbLegacyDriver` decoupled from libplctag types; the 9 unit-test files that build `LibPlcTagException` / `Status` fakes (`AbCipDriverWriteTests`, `AbCipDriverProbeTests`, `AbCipDriverReadTests`, `AbCipStatusMapperTests`, `AbCipDriverCodeReviewRegressionTests`, `AbCipUnknownTagReadTests`, `AbLegacyEvictOnFailureTests`, `AbLegacyReadWriteTests`, `AbLegacyHostAndStatusTests`) moved onto `AbStatus` / `AbTransportException`, with the libplctag translation itself tested in the adapters; the `Transport` option (string enum) + factory selection, including a `--transport` flag on `otopcua-abcip-cli` / `otopcua-ablegacy-cli` (whose help text names libplctag); the source-scan guard | Existing suites green; behaviour identical (default `Libplctag`) |
|
||||
| **P1 Transport core** | Encapsulation, `CipPath`, `ConnectionManager`, `RequestScheduler` (MSP + fragmentation), `DeviceSessionPool` | Golden-frame + scheduler tests |
|
||||
| **P2 AbCip native** | `NativeAbCipTagRuntime`, enumerator, template reader; CIP type check | AbCip integration suite green on both transports vs `ab_server` |
|
||||
| **P3 AbLegacy native** | PCCC layer + `NativeAbLegacyTagRuntime` | AbLegacy integration suite green on both transports vs `ab_server` (slc500 / micrologix / plc5) |
|
||||
| **P4 Switch surface** | AdminUI dropdown, probe honours the switch, docs (`AbCip.md`, `AbLegacy.md`, fixture docs) | docker-dev `/run` |
|
||||
| **P5 Real-hardware gate + flip** | Differential run against the CR presses read-only (VPN from the Mac); a perf comparison (round-trips + p50/p95 poll latency for one press's tag set, native vs libplctag); then **default ⇒ `Native` for both drivers** | AbCip: diff clean on browse / templates / reads, no worse latency, Forward Open count during the run = 1. AbLegacy: simulator proof only, **recorded as a known gap** |
|
||||
| **P6 Delete libplctag** *(conditional)* | Remove the package, adapters and `Libplctag*` members | **Blocked until a bench controller** proves writes (CompactLogix or Micro800) and PCCC (SLC / MicroLogix). Until then libplctag stays selectable |
|
||||
|
||||
### Real-hardware constraints (from `~/Desktop/ignitionoee`)
|
||||
|
||||
- **Reachable targets:** 20 × CompactLogix **5069-L320ERS2 rev 34.11** presses at CR
|
||||
(Z-35162..Z-35181; Z-35162 = `10.209.70.160:44818`), reachable from the Mac over VPN. This is
|
||||
the same rig `cc876bdf` verified against.
|
||||
- **They are production machines and strictly read-only.** P5 performs **no writes**, opens a
|
||||
single connection per press, and touches one press at a time.
|
||||
- **No legacy PCCC hardware is reachable.** The CR presses were confirmed non-MicroLogix by
|
||||
ListIdentity. The Delmia inventory lists PLC-5/04s at Warsaw with no addresses, and as far as I
|
||||
know a PLC-5/04 has no built-in Ethernet port. There is no ControlLogix and no Micro800 either. So
|
||||
routed paths beyond `1,0` and Micro800 UCMM-only mode are simulator-proven only.
|
||||
|
||||
## 9. Known gaps and risks
|
||||
|
||||
- **AbCip writes are simulator-proven only.** Mitigation: Write Tag shares framing with the
|
||||
read-proven path. The risk sits in the encoding details (STRING struct handle, type codes), which
|
||||
have golden tests and run against `ab_server`. libplctag stays selectable.
|
||||
- **AbLegacy is simulator-proven only**, against `ab_server`'s PCCC emulation, which shares
|
||||
libplctag's view of the protocol. This is the weakest evidence in the plan, stated plainly.
|
||||
- **`ab_server` patches** (`0001` symbol listing, `0002` CIP 0x04 for not-found) mean the simulator is
|
||||
already partly ours. A native/libplctag disagreement against it must be checked against the real
|
||||
press before blaming either side.
|
||||
- **The same-width type check changes client-visible quality** (§5).
|
||||
- **Behaviour libplctag hides that we now own:** connection keep-alive, sequence-count wrap, the
|
||||
session-registration lifetime, and the TCP reconnect after an idle teardown by a NAT or firewall.
|
||||
Each one gets a scheduler/connection test.
|
||||
@@ -0,0 +1,479 @@
|
||||
# Native AB transport gate results
|
||||
|
||||
## ab_server suites (T17)
|
||||
|
||||
Every ab_server integration test now runs once per transport (`TransportMatrix` in each integration
|
||||
project). Run 2026-10-07 against `10.100.0.35`, one compose profile at a time
|
||||
(AbCip `/opt/otopcua-abcip`, `AB_SERVER_COMPOSE_PROFILE=<p>`; AbLegacy `~/otopcua-ablegacy`,
|
||||
`AB_LEGACY_COMPOSE_PROFILE=<p>`); every stack was brought down afterwards.
|
||||
|
||||
- **Image:** `otopcua-ab-server:libplctag-release` (`sha256:19e814747d85…`, built 2026-08-01; ab_server from
|
||||
libplctag `v2.7.0` + patches 0001/0002). Host compose files md5-identical to the repo's.
|
||||
- **Commit:** fixes `82d011c7` + `2171fc8c`, test matrix `8c68694d`.
|
||||
|
||||
Counts are pass / fail / skip for the transport-parameterised rows only. Each AbCip run also passed the
|
||||
6 transport-independent `AbServerProfileTests`. The AbCip skips are the 3 Emulate-tier tests
|
||||
(`AB_SERVER_PROFILE=emulate` only). The AbLegacy skips are `Slc500_write_then_read…`, which skips itself
|
||||
on both transports when the slc500 port (`:44819`) is down.
|
||||
|
||||
| Profile | Libplctag | Native | Native ⊇ Libplctag |
|
||||
|---|---|---|---|
|
||||
| controllogix | 1 / 0 / 3 | 1 / 0 / 3 | yes |
|
||||
| compactlogix | 1 / 0 / 3 | 1 / 0 / 3 | yes |
|
||||
| micro800 | 0 / 1 / 3 | 1 / 0 / 3 | yes (libplctag fails, see below) |
|
||||
| guardlogix | 1 / 0 / 3 | 1 / 0 / 3 | yes |
|
||||
| slc500 | 2 / 0 / 0 | 2 / 0 / 0 | yes |
|
||||
| micrologix | 1 / 0 / 1 | 1 / 0 / 1 | yes |
|
||||
| plc5 | 1 / 0 / 1 | 1 / 0 / 1 | yes |
|
||||
|
||||
**Native-only failures found and fixed:**
|
||||
|
||||
- `82d011c7` (controllogix): RegisterSession carried a non-zero sender context, and ab_server rejects that
|
||||
with encapsulation status 1. Every native session failed.
|
||||
- `2171fc8c` (slc500): ab_server answers Execute PCCC with a fixed requestor id
|
||||
(`07 3D F3 45 43 50 21`), so the requestor-echo check rejected every native PCCC reply. Native now sends
|
||||
libplctag's identity.
|
||||
|
||||
**Skips added:** none. The PLC-5 integration test only reads N7, so the PLC-5 Unsupported paths
|
||||
(non-N reads, all writes) and status-bit sub-element writes are never exercised by the suite.
|
||||
|
||||
**micro800:** the smoke test used to send `/1,0` for every family, and both transports failed against
|
||||
`--plc=Micro800`. It now uses an empty path for Micro800. With the empty path, native passes. libplctag
|
||||
still fails with BadCommunicationError, because the driver's libplctag runtime passes `Path = ""` and
|
||||
libplctag answers that with `ErrorBadData`. A `null` path works (checked with the capture client). That
|
||||
defect is pre-existing and libplctag-side; it is recorded, not fixed, here.
|
||||
|
||||
## Differential vs ab_server (T18)
|
||||
|
||||
Run 2026-10-07 on branch `feat/native-ab-transport`, harness
|
||||
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/`, against `ab_server`
|
||||
(`controllogix` profile only, `/opt/otopcua-abcip` on 10.100.0.35):
|
||||
|
||||
```bash
|
||||
AB_DIFF_ENDPOINT=10.100.0.35:44818 AB_DIFF_COUNT_OPENS=1 \
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests \
|
||||
--filter "FullyQualifiedName~Differential" --logger "console;verbosity=detailed"
|
||||
```
|
||||
|
||||
Target as the harness resolved it: `10.100.0.35:44818`, path `1,0`, family `CompactLogix`
|
||||
(the defaults; libplctag attribute `compactlogix`, connection size 504).
|
||||
|
||||
**Result: 7/7 passed, 0 unexpected divergences, 0 expected divergences.**
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Browse parity (`@tags` + program listings) | 9 tags via libplctag, 9 via native, identical as multisets |
|
||||
| Template parity | vacuous: ab_server serves no UDTs, so 0 template ids. Task 21's press is the first real exercise |
|
||||
| Read parity | 9/9 tags equal on status, raw buffer and decoded value |
|
||||
| Native Forward Opens over the run (`AB_DIFF_COUNT_OPENS=1`) | **1** |
|
||||
| TCP connections to the target before the native phase | 0 (baseline 0). libplctag's session closed within 1 ms of the last handle being disposed |
|
||||
| Read-only guard (offline, runs without the endpoint) | pass |
|
||||
| Comparison falsifiability (offline) | pass |
|
||||
|
||||
| Tag | Type | libplctag status | native status | Equal? |
|
||||
|---|---|---|---|---|
|
||||
| `Motor_Run` | Bool | Ok | Ok | yes |
|
||||
| `Line1_Count` | DInt | Ok | Ok | yes |
|
||||
| `Pump_1_Speed` | Real | Ok | Ok | yes |
|
||||
| `TestString` | String | Ok | Ok | yes |
|
||||
| `TestSINT` | SInt | Ok | Ok | yes |
|
||||
| `TestBOOL` | Bool | Ok | Ok | yes |
|
||||
| `TestREAL` | Real | Ok | Ok | yes |
|
||||
| `TestDINT` | DInt | Ok | Ok | yes |
|
||||
| `TestArray` | DInt[16] | Ok | Ok | yes |
|
||||
|
||||
**The values were made non-zero first.** An all-zero simulator would make raw and value equality
|
||||
vacuous. Before the run, the simulator was seeded through a throwaway libplctag scratch program
|
||||
outside the repo. The harness itself never writes. Seeded values:
|
||||
|
||||
- `TestDINT` = −123456789
|
||||
- `TestREAL` = 3.14159
|
||||
- `Pump_1_Speed` = 1234.5
|
||||
- `Line1_Count` = 987654
|
||||
- `TestArray[i]` = (i−8)·100003+7, which includes negatives
|
||||
- `TestString` = `"Hello T18"`
|
||||
|
||||
ab_server refuses 1-byte writes (`ErrorBadParam`), so `TestBOOL`, `TestSINT` and `Motor_Run` stay 0.
|
||||
Their parity is therefore weaker evidence.
|
||||
|
||||
**Wire capture of the same run.** tcpdump ran in the ab_server container's network namespace
|
||||
(`nicolaka/netshoot`), and the capture was decoded with tshark:
|
||||
|
||||
| TCP stream | Session | RegisterSession | Forward Open | Forward Close | Requests | Window (s, relative) |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 12 | libplctag | 1 | 1 (Large, 0x5B) | 1 | 1× 0x55 listing, 18× 0x52 Read Fragmented | 0.243 → 0.336 |
|
||||
| 13 | native | 1 | 1 (0x54) | 1 | 1× 0x55 listing, 18× 0x52 Read Fragmented | 0.349 → 0.452 |
|
||||
|
||||
- Exactly two sessions, with no overlap: a 13 ms gap between them.
|
||||
- Zero write services on the wire: no 0x4D, 0x53 or 0x10.
|
||||
- The 12 earlier sub-10 ms TCP connections are the `AbServerFact`/`AbServerTheory` reachability probes. xUnit runs them during discovery for the *other* suites, even under the filter. They target `AB_SERVER_ENDPOINT` (default 10.100.0.35), never `AB_DIFF_ENDPOINT`.
|
||||
|
||||
**Explicit-list path.** `AB_DIFF_TAGS="TestDINT,NoSuchTag,TestArray,TestString"`: 4/4 equal.
|
||||
`NoSuchTag` returned `NotFound` under both transports, and there was still exactly one native Forward Open.
|
||||
|
||||
**Transport fixes found by the differential: none.** One harness defect was found and fixed before
|
||||
this result. libplctag closes a session as soon as its last handle is destroyed. The first version of the
|
||||
libplctag phase created, read and disposed one handle at a time, so it opened a fresh session (and a
|
||||
fresh Forward Open) per tag: 9+ sessions, which the netstat sampling showed. The libplctag phase now holds an anchor handle
|
||||
on `@tags` for its whole duration, which mirrors the native anchor lease. That leaves the one-session
|
||||
capture above.
|
||||
|
||||
### Review round 1 re-run (press-safety fixes)
|
||||
|
||||
Re-run 2026-10-07 after the review-r1 fixes, with the default pacing. `AB_SERVER_ENDPOINT=127.0.0.1:1`
|
||||
kept the sibling suites' discovery probes off the network:
|
||||
|
||||
```bash
|
||||
AB_SERVER_ENDPOINT=127.0.0.1:1 AB_DIFF_ENDPOINT=10.100.0.35:44818 AB_DIFF_COUNT_OPENS=1 \
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests \
|
||||
--filter "FullyQualifiedName~Differential" --logger "console;verbosity=detailed"
|
||||
```
|
||||
|
||||
**Result: 12/12 passed — 7 live tests and 5 offline safety tests — with 0 unexpected and 0 expected divergences.**
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Settings | pace 20 ms, max tags 200, max templates 50 |
|
||||
| Browse | 9 = 9 |
|
||||
| Selection (array round-robin first) | 1 array + 8 scalars. ab_server has no UDTs, so there were no UDT members and the coverage check had nothing to require |
|
||||
| Reads | 9/9 equal |
|
||||
| Templates | 0 ids, not truncated. Not vacuous by the new rule: ab_server's `STRING` is not a structure, so browse found no structured tags |
|
||||
| Native Forward Opens | **1** |
|
||||
| TCP connections to the target before native | 0 (baseline 0) |
|
||||
|
||||
**Wire capture of this run** (tcpdump in the ab_server container's namespace, decoded with tshark):
|
||||
|
||||
| TCP stream | Session | Forward Open | Forward Close | Requests | Window (s) |
|
||||
|---|---|---|---|---|---|
|
||||
| 0 | libplctag | 1 (Large, 0x5B) | 1 | 1× 0x55, 18× 0x52 | 0.000 → 0.487 |
|
||||
| 1 | native | 1 (0x54) | 1 | 1× 0x55, 18× 0x52 | 0.510 → 0.984 |
|
||||
|
||||
- No discovery probes.
|
||||
- Zero write services: no 0x4D, 0x53, 0x4B or 0x10.
|
||||
- The two sessions are 23 ms apart and do not overlap.
|
||||
|
||||
**The Forward Opens differ between the transports, and that is expected.** libplctag always uses a *Large*
|
||||
Forward Open. Native uses a regular one because the `CompactLogix` profile's connection size (504) is at most 511. Task 21 will
|
||||
see the same split on the press.
|
||||
|
||||
**What changed for press safety (review r1):**
|
||||
|
||||
- **Stop on the first connection-level failure, on either transport.** That covers a refused connect,
|
||||
RegisterSession or Forward Open, a timeout, and a lost session. Any status other than
|
||||
NotFound/NotAllowed/OutOfBounds/TooLarge/TooSmall/Unsupported/TypeMismatch aborts the whole run with the
|
||||
failing operation and status. Nothing is retried.
|
||||
- **The native phase is capped at one open attempt.** It sets the new process-wide
|
||||
`CipConnection.OpenAttemptBudget`, so a reconnect cannot reach the wire at all, even from inside an
|
||||
enumerator that swallows a per-program failure.
|
||||
- **The run also aborts the moment `ForwardOpenCount` passes 1.** This is checked after every browse entry,
|
||||
template and tag.
|
||||
- **Offline pin:** against a loopback listener that accepts and hangs up, the native phase aborts at browse after exactly
|
||||
**one** TCP connect. Removing the harness abort turns the test red. Removing both the abort and the budget
|
||||
turns the connect count red.
|
||||
- **Pacing:** `AB_DIFF_PACE_MS` (default 20) is applied before every harness-issued request: browse,
|
||||
each template read, each tag's create-read and read. Listing *pages* inside the enumerators are not
|
||||
paced.
|
||||
- **Volume caps:** `AB_DIFF_MAX_TEMPLATES` defaults to 50. `AB_DIFF_MAX_TAGS` and `AB_DIFF_MAX_TEMPLATES` must be at most 500;
|
||||
a higher value fails fast instead of being clamped.
|
||||
- **Selection:** the default selection takes arrays (up to 20), UDT members (up to 20) and scalars round-robin, so any cap
|
||||
of 3 or more exercises all three kinds. The reads test fails if browse offered arrays or UDT members
|
||||
but the selection holds none.
|
||||
- **Template parity** fails when browse found structured tags but no template id was compared, and treats an
|
||||
undecodable shape on either side as a mismatch.
|
||||
- **Write-capable integration tests** now require `AB_SERVER_ALLOW_WRITES=1` and refuse while
|
||||
`AB_DIFF_ENDPOINT` is set. Those tests are the Logix Emulate ALMD test and the AbLegacy SLC500 N7:5 round trip.
|
||||
The ab_server controllogix matrix is read-only and unaffected: 15 passed and 11 skipped with
|
||||
`AB_SERVER_COMPOSE_PROFILE=controllogix`.
|
||||
|
||||
### Latency harness proven on ab_server (T21 Step 1)
|
||||
|
||||
`Differential/PollLatencyComparisonTests.cs` (+ `PollLatencyRun.cs`) ran 2026-10-07 against ab_server
|
||||
(`controllogix` image, host port 44828 because an unrelated outbound socket held 44818 on the docker host),
|
||||
under the same `--filter "FullyQualifiedName~Differential"` invocation as the differential. The mechanics work:
|
||||
the timed phase reuses the differential's browse, the anchors, the open budget and the barriers; **1** native Forward Open
|
||||
in the latency phase; 30 passes per transport, 0 tag failures; native packs 100 concurrent reads into **6** requests per pass.
|
||||
ab_server has 7 atomic scalars, so the 100-tag set was **repeated cyclically** (reported in the output).
|
||||
|
||||
| Family | libplctag p50 / p95 ms | native p50 / p95 ms | native requests/pass | Criterion |
|
||||
|---|---|---|---|---|
|
||||
| CompactLogix (504, the press default) | 7.7 / 10.9 | 22.1 / 25.3 | 6 | **FAIL** |
|
||||
| ControlLogix (4002) | 7.3 / 9.1 | 7.7 / 9.2 | 2 | pass |
|
||||
|
||||
The CompactLogix result was a real finding, not a harness fault. Native opened a 504-byte connection there, while libplctag
|
||||
opened a Large one (see the T18 capture above), so native needed 3× the round trips per pass.
|
||||
|
||||
**Fixed:** the CompactLogix profile now defaults to 4002, so native opens Large like libplctag. A refusal still falls back to
|
||||
a regular 504 open. Re-run 2026-10-07 23:30–23:32Z against the `compactlogix` compose profile on :44818 (3 distinct atomic
|
||||
tags, repeated to 100), with a capture in the container's network namespace:
|
||||
|
||||
| Run | libplctag p50 / p95 ms | native p50 / p95 ms | native req/pass | Criterion |
|
||||
|---|---|---|---|---|
|
||||
| 1 | — | — | — | aborted in the latency libplctag phase (see below) |
|
||||
| 2 | 7.6 / 9.6 | 7.5 / 10.8 | 2 | FAIL (limit 10.6) |
|
||||
| 3 | 7.8 / 9.3 | 8.1 / 9.4 | 2 | PASS (limit 10.2) |
|
||||
| 4 | 7.7 / 10.0 | 7.8 / 11.7 | 2 | FAIL (limit 11.1) |
|
||||
| 5 | — | — | — | aborted in the latency libplctag phase |
|
||||
|
||||
The structural gap is gone: p50s are equal, and both transports now send 2 requests per pass. At ~10 ms the p95 margin is about
|
||||
1 ms, which is within this network path's jitter, so the ab_server verdict varies from run to run.
|
||||
|
||||
Capture: every session opened with **0x5B (Large Forward Open)** and was accepted (0xDB, status 0). That covers 15 sessions:
|
||||
four per complete invocation and three in the aborted run 5. Each session had one Forward Close (0x4E to Connection Manager 6/1).
|
||||
There were **zero** 0x4D/0x53/0x4B. Both aborts (runs 1 and 5) were the simulator: libplctag's ~2 KB packed request was split
|
||||
across TCP segments (1448 + 606), and ab_server answered with FIN. The harness aborted on the first connection-level failure,
|
||||
as designed. A real controller reassembles TCP; ab_server does not.
|
||||
|
||||
The T21 press result is recorded under `### Pass 3 — 2026-10-08 02:27–02:30 UTC (HEAD `8e2dbc67`, after the final-review fixes; same settings as pass 2) — with packet capture
|
||||
|
||||
**17/17 passed.** Browse 3162 = 3162 tags; 20 UDT templates identical; 200 tags read with 0 unexpected divergences
|
||||
(6 live); native 1 Forward Open, Forward Closes sent/acked 1/1; latency native p95 105.1 ms ≤ libplctag p95 197.3 ms
|
||||
×1.10 = 217.1 ms → PASS. Raw output: [pass 3](2026-10-07-native-ab-transport-t21/t21-pass3.txt).
|
||||
|
||||
Packet capture (`tcpdump -i utun4 host 10.209.70.160 and port 44818`, analysed with TCP sequence reassembly):
|
||||
|
||||
| Session | Order | Window (s) | CIP services seen (requests) | Forward Close target |
|
||||
|---|---|---|---|---|
|
||||
| libplctag differential | 1 | 0.0 – 82.5 | 0x03, 0x4C, 0x52, 0x55, 0x5B ×1, 0x4E ×1 | Connection Manager 6/1 |
|
||||
| native differential | 2 | 82.5 – 126.3 | 0x03, 0x4C, 0x52, 0x55, 0x5B ×1, 0x4E ×1 (+ UnRegisterSession) | Connection Manager 6/1 |
|
||||
| libplctag latency | 3 | 126.5 – 152.4 | 0x0A (MSP), 0x52, 0x5B ×1, 0x4E ×1 | Connection Manager 6/1 |
|
||||
| native latency | 4 | 152.4 – 176.5 | 0x0A (MSP), 0x52, 0x5B ×1, 0x4E ×1 (+ UnRegisterSession) | Connection Manager 6/1 |
|
||||
|
||||
- **Zero write-class services** (no 0x4D Write Tag, 0x53 Write Tag Fragmented, 0x4B Execute PCCC, and no 0x4E to
|
||||
anything but the Connection Manager).
|
||||
- **Four sessions, strictly sequential** — each starts after the previous one's last packet; never two at once.
|
||||
- Every session opened exactly one Large Forward Open and closed it with one Forward Close.
|
||||
|
||||
## Latency (T21)` and is not filled in here.
|
||||
|
||||
## AdminUI /run (T19)
|
||||
|
||||
Run 2026-10-07 on the local docker-dev rig (OrbStack), against the ab_server `controllogix` fixture
|
||||
on `10.100.0.35:44818`. Every container ran the same freshly built image
|
||||
(`otopcua-host:dev` `sha256:91bffbb0…`, checked per container because `:9200` round-robins). The
|
||||
modal was driven with the Chrome tools against `http://localhost:9200/raw`.
|
||||
|
||||
**Authoring (UI).**
|
||||
- Created the AbCip driver `t19-abcip` (`DRV-8ea0f0a8cfde`) in the MAIN cluster.
|
||||
- Configure → Transport = "Native (managed)" → Save. `DriverInstance.DriverConfig` then held
|
||||
`"transport":"Native"`, a string.
|
||||
- Reopened Configure. The dropdown showed "Native (managed)". Changed only Timeout from 2 to 3 and
|
||||
saved. The row then held `"transport":"Native"` and `"timeout":"00:00:03"`, so the save-reverts-transport
|
||||
defect is fixed live, not only in unit tests.
|
||||
- Created device `Device1plc1` with host `ab://10.100.0.35:44818/1,0` (ControlLogix). Test Connect
|
||||
returned `OK · 62 ms`.
|
||||
- Added the raw tag `TestDINT` (Int32, read-only, tag path `TestDINT`, DInt).
|
||||
- Seeded `TestDINT = 4242` on the simulator so a non-zero value proves the read path.
|
||||
- Raw namespace: `ns=2`, the shared `s=OtOpcUa` root plus RawPath ids. UNS uses `ns=3`.
|
||||
- RawPath: `t19-abcip/Device1plc1/TestDINT`.
|
||||
|
||||
**Leg 1: Native.** The deploy sealed with `acks=6`. Both central nodes logged:
|
||||
```
|
||||
AbCip driver DRV-8ea0f0a8cfde initializing: transport=Native tagFactory=NativeAbCipTagFactory
|
||||
```
|
||||
CLI read on central-1 (`:4840`) and central-2 (`:4841`):
|
||||
```
|
||||
Node: ns=2;s=t19-abcip/Device1plc1/TestDINT
|
||||
Value: 4242
|
||||
Status: 0x00000000
|
||||
```
|
||||
The simulator value was then changed to 5150 through the libplctag CLI. The native-served node
|
||||
followed it (`Value: 5150`, `Status: 0x00000000`, source time `23:02:09.60Z`).
|
||||
|
||||
**Leg 2: inverted gate (Native, dead port).**
|
||||
- Device host changed to `ab://10.100.0.35:44819/1,0`. Nothing listens on 44819
|
||||
(`ss -ltn` count 0).
|
||||
- The node went `BadNoCommunication` (value retained). A CLI read failed with
|
||||
`ServiceResultException: [80050000]` (BadCommunicationError). The CLI `read` throws on a Bad
|
||||
status rather than printing it.
|
||||
- The central-1 log showed the native error text, raised from `NativeAbCipTagRuntime.ReadAsync`:
|
||||
```
|
||||
ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbTransportException: TCP connect to 10.100.0.35:44819 failed: ConnectionRefused
|
||||
---> System.Net.Sockets.SocketException (111): Connection refused
|
||||
```
|
||||
- For contrast, the same dead port under libplctag logged different wording:
|
||||
```
|
||||
ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbTransportException: ErrorTimeout
|
||||
---> libplctag.LibPlcTagException: ErrorTimeout
|
||||
```
|
||||
So the switch changes behaviour and is not inert.
|
||||
|
||||
**Leg 3: back to libplctag.**
|
||||
- Device restored to port 44818. Configure → Transport = "libplctag (default)" → Save. The row held
|
||||
`"transport":"Libplctag"`.
|
||||
- The redeploy sealed with `acks=6`. Both centrals logged
|
||||
`initializing: transport=Libplctag tagFactory=LibplctagTagFactory`.
|
||||
- CLI reads on `:4840` and `:4841`: `Value: 5150`, `Status: 0x00000000`.
|
||||
|
||||
**Fix found by the gate.** The first deploy logged no transport line. `AbCipDriverFactoryExtensions.Register`
|
||||
took no `ILoggerFactory`, and `DriverFactoryBootstrap` registered AbCip without one, so every production
|
||||
AbCip driver ran on `NullLogger`. That hid the new line and every existing AbCip warning or error. AbCip
|
||||
now takes the host logger factory the same way AbLegacy and Modbus do. `AbCipTransportInitLogTests` is
|
||||
falsified by reverting that wiring: 2/2 tests fail.
|
||||
|
||||
**Cleanup.**
|
||||
- The `t19-abcip` driver, device and tag rows were deleted and the change redeployed (sealed,
|
||||
`acks=6`). Both centrals logged `stopped driver child DRV-8ea0f0a8cfde`.
|
||||
- The rig has no test driver pointing at the fixture.
|
||||
- The fixture was brought down (`docker compose --profile controllogix down`).
|
||||
- The simulator's `TestDINT` was left at 5150 until teardown. The container is gone, so the value
|
||||
does not persist.
|
||||
|
||||
## Real press (T21)
|
||||
|
||||
Target: **Z-35162**, production CompactLogix 5069-L320ERS2, `10.209.70.160:44818`, path `1,0`, family CompactLogix
|
||||
(native connection size 4002, Large Forward Open). Read-only. User approved the press and each time window; VPN up;
|
||||
no other client on this Mac connected to the press. Driven from the Mac over VPN (≈44 ms RTT).
|
||||
|
||||
### Pass 1 — 2026-10-07 23:35–23:36 UTC (HEAD `c306c6f6`; 25 tags, 20 templates, pace 50 ms, latency 100×30)
|
||||
|
||||
12/14 passed. **Safety held**: packet capture (`tcpdump` on `utun4`) shows 4 sequential sessions, each one Large
|
||||
Forward Open (0x5B) and one Forward Close (0x4E, Connection Manager 6/1, acked); services seen only 0x03, 0x0A, 0x4C,
|
||||
0x52, 0x55, 0x5B, 0x4E; **zero writes** (no 0x4D/0x53/0x4B). Browse parity and all 20 UDT template shapes identical;
|
||||
native Forward Opens = 1.
|
||||
|
||||
Failures, and what they led to:
|
||||
- **Read parity, 2 tags** — live analog values moved between the sequential libplctag and native phases
|
||||
(`WW_M4ActualTemp` 384.887 → 385.138). Harness defect: it could not tell drift from divergence. Fixed by a volatility
|
||||
probe (`82a0c298`, tightened element-wise in `43b81b66`): a value that moves between two libplctag reads is
|
||||
compared by status, length, type, shape and **every element that held still**.
|
||||
- **Latency** — native p95 142.8 ms vs libplctag 99.1 ms ×1.10 = 109.1 ms (FAIL), medians native 93.7 / libplctag
|
||||
95.2 ms; three native outliers in 30 passes. Criterion unchanged; pass count raised to 50 and per-pass GC/RTT
|
||||
diagnostics added.
|
||||
- **Test host crashed at exit** — `A callback was made on a garbage collected delegate … callback_func_ex`. Root cause
|
||||
is in libplctag.NET 1.5.2: a failed tag create leaves its native callback registered and `Dispose()` doesn't release
|
||||
it. **Production-reachable** on the default transport (a nonexistent tag name crashes the server node at
|
||||
stop/restart). Fixed in the adapters (`6cd68762`, hardened `6ae62873`); Gitea #578.
|
||||
- Found before pass 1, on ab_server: native CompactLogix opened a 504-byte connection (the family default libplctag
|
||||
never honoured) and needed ~3× the round-trips. Native default changed to 4002 with the existing Large→regular
|
||||
fallback (`c306c6f6`).
|
||||
|
||||
### Pass 2 — 2026-10-08 00:21–00:24 UTC (HEAD `df776170`, fresh build; 200 tags, 20 templates, pace 50 ms, latency 100×50)
|
||||
|
||||
**17/17 passed.**
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Browse parity | libplctag 3162 tags, native 3162 — identical sets |
|
||||
| UDT templates | 20 compared (of 74 found, capped) — identical shapes |
|
||||
| Read parity | 200 tags: **0 unexpected**, 0 expected (TypeMismatch) divergences; 6 live tags (below) |
|
||||
| Native Forward Opens | 1 per native phase |
|
||||
| Native Forward Closes sent/acked | 1/1 per native phase; 0 connections left |
|
||||
| Sessions | libplctag and native never connected at the same time |
|
||||
| Writes | none — read-only source guard passes; `grep -c WriteAsync` = 0 over every Differential file |
|
||||
| libplctag failed-create orphans | 15 destroyed by the #578 fix, 0 rooted — no crash at exit |
|
||||
|
||||
⚠️ **Pass 2 has no independent packet capture** (pass 3 below re-ran it with one): the operator's `tcpdump` had stopped at 23:45 UTC, before pass 2 began,
|
||||
and this was not noticed until afterwards. The read-only evidence for pass 2 is the source guard and the harness's
|
||||
own Forward Open/Close counters; pass 1's capture is the wire-level proof for the same code paths.
|
||||
|
||||
Live tags (value moved between the two libplctag reads; every element that held still matched native):
|
||||
|
||||
```
|
||||
- live: WW_M4ActualTemp (lib 4 byte(s) 0CCDC243 = 389.60193; lib probe 4 byte(s) 7084C243 = 389.03467; native 4 byte(s) 4C47C243 = 388.557)
|
||||
- live: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5 (lib 20 byte(s) 0050C348218986402189A640DF76D9C021898640 = [400000,4.2042394,5.2042394,-6.7957606,4.2042394]; lib probe 20 byte(s) 0050C348E74B8640E74BA64019B4D9C0E74B8640 = [400000,4.1967654,5.1967654,-6.8032346,4.1967654]; native 20 byte(s) 0050C348BB328640BB32A64045CDD9C0BB328640 = [400000,4.1936927,5.1936927,-6.8063073,4.1936927])
|
||||
- live: Program:EM02_Clamp.__fout2F67CD35C2302EE0 (lib 16 byte(s) BFB2BF379A9919BF00D68349CDCCCCBE = [2.285221E-05,-0.6,1080000,-0.4]; lib probe 16 byte(s) 5119313A9A9919BF00D68349CDCCCCBE = [0.00067557866,-0.6,1080000,-0.4]; native 16 byte(s) 1562C2B99A9919BF00D68349CDCCCCBE = [-0.0003707564,-0.6,1080000,-0.4])
|
||||
- live: Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB (lib 16 byte(s) 00E14CC0CDDA75BF000000C08F2D2BC0 = [-3.201233,-0.9603699,-2,-2.6746557]; lib probe 16 byte(s) 009464C09A2589BF000000C0A84932C0 = [-3.5715332,-1.07146,-2,-2.7857456]; native 16 byte(s) 00A03AC034F35FBF000000C0A8B325C0 = [-2.9160156,-0.87480474,-2,-2.5890903])
|
||||
- live: Program:ES03_EM01_Zone1.__fout92848E133B44AF24 (lib 32 byte(s) 40EDA94160F0D043000090C10022A14980256C41C656D143006A4F400000A8C1 = [21.240845,417.87793,-18,1320000,14.759155,418.67792,3.2408447,-21]; lib probe 32 byte(s) 6017AD412089D043000090C10022A14940D1654186EFD04300BB68400000A8C1 = [21.636414,417.0713,-18,1320000,14.363586,417.87128,3.6364136,-21]; native 32 byte(s) C047A841BE97CF43000090C10022A14980706F4124FECF43003E42400000A8C1 = [21.035034,415.1855,-18,1320000,14.964966,415.98547,3.0350342,-21])
|
||||
- live: Program:ES03_EM01_Zone1.__fout6DC7745628658A9D (lib 8 byte(s) 00824E40CD34A53F = [3.2266846,1.2906739]; lib probe 8 byte(s) 008A6440CDD4B63F = [3.5709229,1.4283692]; native 8 byte(s) 000D3940670A943F = [2.8914185,1.1565675])
|
||||
```
|
||||
|
||||
Test summary:
|
||||
|
||||
```
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [26 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [17 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [4 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [15 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [3 m 2 s]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_closes_its_one_connection_with_exactly_one_forward_close [< 1 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.No_failed_libplctag_tag_is_left_with_a_live_native_callback [< 1 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.A_live_value_is_compared_structurally_and_a_stable_one_by_value [3 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [36 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [3 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
|
||||
Total tests: 17
|
||||
Passed: 17
|
||||
```
|
||||
|
||||
Full raw `dotnet test` output: [pass 1](2026-10-07-native-ab-transport-t21/t21-pass1.txt), [pass 2](2026-10-07-native-ab-transport-t21/t21-pass2.txt).
|
||||
|
||||
## Latency (T21)
|
||||
|
||||
Pass 2 (100 atomic tags × 50 passes per transport, 200 ms gap, all reads in a pass issued concurrently):
|
||||
|
||||
| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| libplctag | 50 | 107.0 | 139.5 | 57.9 | 164.8 | n/a | 0 |
|
||||
| native | 50 | 100.3 | 105.8 | 94.8 | 107.5 | 2 | 0 |
|
||||
|
||||
**Criterion: native p95 105.8 ms ≤ libplctag p95 139.5 ms × 1.10 = 153.4 ms → PASS.** Native was faster at the median
|
||||
and much steadier (max 107.5 ms vs 164.8 ms), packing 95 reads into 2 CIP requests per pass. Five pad/config tags that
|
||||
libplctag refused at setup were removed from both sets (`Local:*:I.Pad1`, `Local:5:C.CfgSize`, `Local:5:C.CfgIDNum`).
|
||||
|
||||
Pass 1 (30 passes) failed this criterion on three native outliers (p95 142.8 vs limit 109.1; medians native 93.7 /
|
||||
libplctag 95.2) — see above.
|
||||
|
||||
Per-pass detail (pass 2):
|
||||
|
||||
```
|
||||
| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | 104.8 | 0/0/0 | 100.3 | 0/0/0 | 2 | 44.9/49.6/54.4 |
|
||||
| 2 | 57.9 | 0/0/0 | 100.5 | 0/0/0 | 2 | 47.0/50.1/53.3 |
|
||||
| 3 | 139.5 | 0/0/0 | 105.8 | 0/0/0 | 2 | 45.3/52.9/60.4 |
|
||||
| 4 | 104.5 | 0/0/0 | 100.6 | 1/0/0 | 2 | 47.3/50.2/53.2 |
|
||||
| 5 | 108.0 | 0/0/0 | 105.3 | 0/0/0 | 2 | 44.5/52.6/60.6 |
|
||||
| 6 | 104.4 | 0/0/0 | 105.6 | 0/0/0 | 2 | 44.2/52.7/61.2 |
|
||||
| 7 | 60.8 | 0/0/0 | 98.7 | 0/0/0 | 2 | 44.3/49.3/54.2 |
|
||||
| 8 | 59.6 | 0/0/0 | 102.6 | 0/0/0 | 2 | 47.4/51.2/55.0 |
|
||||
| 9 | 109.2 | 0/0/0 | 97.0 | 0/0/0 | 2 | 45.9/48.4/50.9 |
|
||||
| 10 | 109.7 | 0/0/0 | 99.3 | 0/0/0 | 2 | 45.9/49.6/53.3 |
|
||||
| 11 | 105.9 | 0/0/0 | 103.0 | 0/0/0 | 2 | 46.2/51.4/56.7 |
|
||||
| 12 | 107.7 | 0/0/0 | 95.1 | 0/0/0 | 2 | 43.3/47.5/51.7 |
|
||||
| 13 | 104.7 | 0/0/0 | 104.0 | 0/0/0 | 2 | 45.7/52.0/58.2 |
|
||||
| 14 | 106.8 | 0/0/0 | 96.5 | 0/0/0 | 2 | 43.6/48.2/52.7 |
|
||||
| 15 | 107.2 | 0/0/0 | 102.4 | 0/0/0 | 2 | 46.1/51.1/56.2 |
|
||||
| 16 | 105.5 | 0/0/0 | 97.4 | 0/0/0 | 2 | 44.7/48.6/52.6 |
|
||||
| 17 | 105.9 | 0/0/0 | 107.5 | 0/0/0 | 2 | 51.0/53.7/56.4 |
|
||||
| 18 | 103.2 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.0/48.2/53.5 |
|
||||
| 19 | 106.5 | 0/0/0 | 102.1 | 0/0/0 | 2 | 45.5/51.0/56.4 |
|
||||
| 20 | 106.8 | 0/0/0 | 99.6 | 0/0/0 | 2 | 44.6/49.7/54.9 |
|
||||
| 21 | 122.8 | 0/0/0 | 104.5 | 0/0/0 | 2 | 52.1/52.2/52.3 |
|
||||
| 22 | 109.8 | 0/0/0 | 104.3 | 0/0/0 | 2 | 45.8/52.1/58.5 |
|
||||
| 23 | 104.3 | 0/0/0 | 98.6 | 0/0/0 | 2 | 45.4/49.2/53.0 |
|
||||
| 24 | 109.7 | 0/0/0 | 98.8 | 0/0/0 | 2 | 45.9/49.3/52.8 |
|
||||
| 25 | 155.1 | 0/0/0 | 101.0 | 0/0/0 | 2 | 45.5/50.4/55.4 |
|
||||
| 26 | 164.8 | 0/0/0 | 102.9 | 0/0/0 | 2 | 43.9/51.4/58.8 |
|
||||
| 27 | 120.4 | 0/0/0 | 101.4 | 0/0/0 | 2 | 48.2/50.7/53.1 |
|
||||
| 28 | 108.6 | 0/0/0 | 101.7 | 0/0/0 | 2 | 46.9/50.8/54.7 |
|
||||
| 29 | 106.0 | 0/0/0 | 98.5 | 0/0/0 | 2 | 45.5/49.2/52.9 |
|
||||
| 30 | 102.9 | 0/0/0 | 94.9 | 0/0/0 | 2 | 43.6/47.4/51.2 |
|
||||
| 31 | 111.3 | 0/0/0 | 99.9 | 0/0/0 | 2 | 45.6/49.9/54.2 |
|
||||
| 32 | 103.3 | 0/0/0 | 96.4 | 0/0/0 | 2 | 43.6/48.1/52.6 |
|
||||
| 33 | 105.8 | 0/0/0 | 104.6 | 0/0/0 | 2 | 46.4/52.2/58.0 |
|
||||
| 34 | 109.3 | 0/0/0 | 95.8 | 0/0/0 | 2 | 43.2/47.8/52.4 |
|
||||
| 35 | 110.2 | 0/0/0 | 102.1 | 0/0/0 | 2 | 46.3/51.0/55.7 |
|
||||
| 36 | 108.0 | 0/0/0 | 101.7 | 0/0/0 | 2 | 47.5/50.8/54.0 |
|
||||
| 37 | 107.6 | 0/0/0 | 96.7 | 0/0/0 | 2 | 45.0/48.3/51.5 |
|
||||
| 38 | 108.9 | 0/0/0 | 96.5 | 0/0/0 | 2 | 44.7/48.2/51.6 |
|
||||
| 39 | 105.2 | 0/0/0 | 98.2 | 0/0/0 | 2 | 45.6/49.0/52.5 |
|
||||
| 40 | 106.7 | 0/0/0 | 99.4 | 0/0/0 | 2 | 44.4/49.6/54.9 |
|
||||
| 41 | 107.0 | 0/0/0 | 101.8 | 0/0/0 | 2 | 46.9/50.8/54.8 |
|
||||
| 42 | 108.6 | 0/0/0 | 106.7 | 0/0/0 | 2 | 49.2/53.3/57.4 |
|
||||
| 43 | 107.0 | 0/0/0 | 102.6 | 0/0/0 | 2 | 49.9/51.2/52.6 |
|
||||
| 44 | 107.7 | 0/0/0 | 100.3 | 0/0/0 | 2 | 47.4/50.1/52.7 |
|
||||
| 45 | 109.5 | 0/0/0 | 102.6 | 0/0/0 | 2 | 45.8/51.2/56.7 |
|
||||
| 46 | 107.6 | 0/0/0 | 104.0 | 0/0/0 | 2 | 48.4/52.0/55.5 |
|
||||
| 47 | 115.6 | 0/0/0 | 99.0 | 0/0/0 | 2 | 45.9/49.4/52.9 |
|
||||
| 48 | 106.0 | 0/0/0 | 97.8 | 0/0/0 | 2 | 44.9/48.8/52.8 |
|
||||
| 49 | 104.5 | 0/0/0 | 98.4 | 0/0/0 | 2 | 46.1/49.1/52.2 |
|
||||
| 50 | 112.5 | 0/0/0 | 94.8 | 1/1/1 | 2 | 43.0/47.3/51.7 |
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
|
||||
Test run for /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll (.NETCoreApp,Version=v10.0)
|
||||
VSTest version 18.0.1 (arm64)
|
||||
|
||||
Starting test execution, please wait...
|
||||
A total of 1 test files matched the specified pattern.
|
||||
/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll
|
||||
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.0.2+dd36e86129 (64-bit .NET 10.0.5)
|
||||
[xUnit.net 00:00:00.33] Discovering: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
[xUnit.net 00:00:00.46] Discovered: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
[xUnit.net 00:00:00.57] Starting: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [11 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [7 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [2 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
|
||||
Standard Output Messages:
|
||||
libplctag connects before the abort: 1
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [13 ms]
|
||||
[xUnit.net 00:01:15.30] ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [FAIL]
|
||||
[xUnit.net 00:01:15.30] Shouldly.ShouldAssertException : natP95
|
||||
[xUnit.net 00:01:15.30] should be less than or equal to
|
||||
[xUnit.net 00:01:15.30] 109.06115000000001d
|
||||
[xUnit.net 00:01:15.30] but was
|
||||
[xUnit.net 00:01:15.30] 142.765d
|
||||
[xUnit.net 00:01:15.30]
|
||||
[xUnit.net 00:01:15.30] Additional Info:
|
||||
[xUnit.net 00:01:15.30] native p95 must be ≤ libplctag p95 × 1.10
|
||||
[xUnit.net 00:01:15.30] Stack Trace:
|
||||
[xUnit.net 00:01:15.30] /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs(74,0): at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag()
|
||||
[xUnit.net 00:01:15.30] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
|
||||
[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
|
||||
[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
|
||||
[xUnit.net 00:01:15.30] --- End of stack trace from previous location ---
|
||||
[xUnit.net 00:01:15.30] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
|
||||
[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
|
||||
[xUnit.net 00:01:15.30] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
|
||||
[xUnit.net 00:01:15.30] Output:
|
||||
[xUnit.net 00:01:15.30] ## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
|
||||
[xUnit.net 00:01:15.30] Tags per pass: 95 (100 distinct); passes per transport: 30; gap between passes: 200 ms.
|
||||
[xUnit.net 00:01:15.30] Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
|
||||
[xUnit.net 00:01:15.30] Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
|
||||
[xUnit.net 00:01:15.30] Native requests spent on setup (one read per tag): 95; native Forward Opens: 1.
|
||||
[xUnit.net 00:01:15.30]
|
||||
[xUnit.net 00:01:15.30] | Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
|
||||
[xUnit.net 00:01:15.30] |---|---|---|---|---|---|---|---|
|
||||
[xUnit.net 00:01:15.30] | libplctag | 30 | 95.2 | 99.1 | 90.8 | 179.2 | n/a | 0 |
|
||||
[xUnit.net 00:01:15.30] | native | 30 | 93.7 | 142.8 | 88.8 | 146.1 | 2 | 0 |
|
||||
[xUnit.net 00:01:15.30]
|
||||
[xUnit.net 00:01:15.30] Criterion: native p95 142.8 ms ≤ libplctag p95 99.1 ms × 1.10 = 109.1 ms → **FAIL**
|
||||
[xUnit.net 00:01:15.30]
|
||||
[xUnit.net 00:01:15.30] | Pass | libplctag ms | native ms | native requests |
|
||||
[xUnit.net 00:01:15.30] |---|---|---|---|
|
||||
[xUnit.net 00:01:15.30] | 1 | 95.0 | 95.4 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 2 | 94.4 | 92.4 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 3 | 95.8 | 96.8 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 4 | 95.0 | 94.7 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 5 | 98.8 | 93.7 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 6 | 92.4 | 101.0 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 7 | 94.1 | 94.1 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 8 | 97.0 | 96.7 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 9 | 93.4 | 91.2 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 10 | 97.7 | 91.0 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 11 | 94.6 | 91.1 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 12 | 96.5 | 92.0 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 13 | 94.1 | 97.6 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 14 | 96.1 | 94.0 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 15 | 90.8 | 93.3 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 16 | 95.6 | 93.7 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 17 | 93.9 | 100.6 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 18 | 92.4 | 121.6 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 19 | 97.5 | 92.7 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 20 | 95.2 | 93.8 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 21 | 96.8 | 90.4 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 22 | 98.6 | 99.1 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 23 | 92.1 | 146.1 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 24 | 99.1 | 142.8 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 25 | 96.6 | 91.7 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 26 | 96.9 | 91.4 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 27 | 179.2 | 92.0 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 28 | 98.5 | 91.7 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 29 | 95.0 | 88.8 | 2 |
|
||||
[xUnit.net 00:01:15.30] | 30 | 95.0 | 93.4 | 2 |
|
||||
Failed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [1 m 6 s]
|
||||
Error Message:
|
||||
Shouldly.ShouldAssertException : natP95
|
||||
should be less than or equal to
|
||||
109.06115000000001d
|
||||
but was
|
||||
142.765d
|
||||
|
||||
Additional Info:
|
||||
native p95 must be ≤ libplctag p95 × 1.10
|
||||
Stack Trace:
|
||||
at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag() in /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs:line 74
|
||||
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
|
||||
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
|
||||
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
|
||||
--- End of stack trace from previous location ---
|
||||
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
|
||||
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
|
||||
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
|
||||
Standard Output Messages:
|
||||
## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
|
||||
Tags per pass: 95 (100 distinct); passes per transport: 30; gap between passes: 200 ms.
|
||||
Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
|
||||
Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
|
||||
Native requests spent on setup (one read per tag): 95; native Forward Opens: 1.
|
||||
|
||||
| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| libplctag | 30 | 95.2 | 99.1 | 90.8 | 179.2 | n/a | 0 |
|
||||
| native | 30 | 93.7 | 142.8 | 88.8 | 146.1 | 2 | 0 |
|
||||
|
||||
Criterion: native p95 142.8 ms ≤ libplctag p95 99.1 ms × 1.10 = 109.1 ms → **FAIL**
|
||||
|
||||
| Pass | libplctag ms | native ms | native requests |
|
||||
|---|---|---|---|
|
||||
| 1 | 95.0 | 95.4 | 2 |
|
||||
| 2 | 94.4 | 92.4 | 2 |
|
||||
| 3 | 95.8 | 96.8 | 2 |
|
||||
| 4 | 95.0 | 94.7 | 2 |
|
||||
| 5 | 98.8 | 93.7 | 2 |
|
||||
| 6 | 92.4 | 101.0 | 2 |
|
||||
| 7 | 94.1 | 94.1 | 2 |
|
||||
| 8 | 97.0 | 96.7 | 2 |
|
||||
| 9 | 93.4 | 91.2 | 2 |
|
||||
| 10 | 97.7 | 91.0 | 2 |
|
||||
| 11 | 94.6 | 91.1 | 2 |
|
||||
| 12 | 96.5 | 92.0 | 2 |
|
||||
| 13 | 94.1 | 97.6 | 2 |
|
||||
| 14 | 96.1 | 94.0 | 2 |
|
||||
| 15 | 90.8 | 93.3 | 2 |
|
||||
| 16 | 95.6 | 93.7 | 2 |
|
||||
| 17 | 93.9 | 100.6 | 2 |
|
||||
| 18 | 92.4 | 121.6 | 2 |
|
||||
| 19 | 97.5 | 92.7 | 2 |
|
||||
| 20 | 95.2 | 93.8 | 2 |
|
||||
| 21 | 96.8 | 90.4 | 2 |
|
||||
| 22 | 98.6 | 99.1 | 2 |
|
||||
| 23 | 92.1 | 146.1 | 2 |
|
||||
| 24 | 99.1 | 142.8 | 2 |
|
||||
| 25 | 96.6 | 91.7 | 2 |
|
||||
| 26 | 96.9 | 91.4 | 2 |
|
||||
| 27 | 179.2 | 92.0 | 2 |
|
||||
| 28 | 98.5 | 91.7 | 2 |
|
||||
| 29 | 95.0 | 88.8 | 2 |
|
||||
| 30 | 95.0 | 93.4 | 2 |
|
||||
|
||||
|
||||
[xUnit.net 00:01:15.35] ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [FAIL]
|
||||
[xUnit.net 00:01:15.35] Shouldly.ShouldAssertException : unexpected
|
||||
[xUnit.net 00:01:15.35] should be empty but had
|
||||
[xUnit.net 00:01:15.35] 2
|
||||
[xUnit.net 00:01:15.35] items and was
|
||||
[xUnit.net 00:01:15.35] ["WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)", "Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])"]
|
||||
[xUnit.net 00:01:15.35] Stack Trace:
|
||||
[xUnit.net 00:01:15.35] /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs(123,0): at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports()
|
||||
[xUnit.net 00:01:15.35] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
|
||||
[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
|
||||
[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
|
||||
[xUnit.net 00:01:15.35] --- End of stack trace from previous location ---
|
||||
[xUnit.net 00:01:15.35] at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
|
||||
[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
|
||||
[xUnit.net 00:01:15.35] at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
|
||||
[xUnit.net 00:01:15.35] Output:
|
||||
[xUnit.net 00:01:15.35] ## Reads — 25 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
|
||||
[xUnit.net 00:01:15.35] Selection by origin: array 9, udt member 8, scalar 8
|
||||
[xUnit.net 00:01:15.35] | Tag | Type | libplctag status | native status | Equal? |
|
||||
[xUnit.net 00:01:15.35] |---|---|---|---|---|
|
||||
[xUnit.net 00:01:15.35] | `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | yes |
|
||||
[xUnit.net 00:01:15.35] | `Local:1:I.InfoBits` | SInt | Ok | Ok | yes |
|
||||
[xUnit.net 00:01:15.35] | `__CONTAINER` | DInt | Ok | Ok | yes |
|
||||
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | yes |
|
||||
[xUnit.net 00:01:15.35] | `Local:1:I.RunMode` | Bool | Ok | Ok | yes |
|
||||
[xUnit.net 00:01:15.35] | `WW_M4ActualTemp` | Real | Ok | Ok | **NO: raw** |
|
||||
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | yes |
|
||||
[xUnit.net 00:01:15.35] | `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | yes |
|
||||
[xUnit.net 00:01:15.35] | `WW_P1_Status_Msg` | DInt | Ok | Ok | yes |
|
||||
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **NO: raw** |
|
||||
[xUnit.net 00:01:15.35] | `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | yes |
|
||||
[xUnit.net 00:01:15.35] | `DI_CycleStart_PB` | Bool | Ok | Ok | yes |
|
||||
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | yes |
|
||||
[xUnit.net 00:01:15.35] | `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | yes |
|
||||
[xUnit.net 00:01:15.35] | `test` | DInt | Ok | Ok | yes |
|
||||
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | yes |
|
||||
[xUnit.net 00:01:15.35] | `Local:1:I.Pad1` | Int | NotFound | NotFound | yes |
|
||||
[xUnit.net 00:01:15.35] | `UN01_EventsPTR` | DInt | Ok | Ok | yes |
|
||||
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | yes |
|
||||
[xUnit.net 00:01:15.35] | `Local:2:I.InfoBits` | SInt | Ok | Ok | yes |
|
||||
[xUnit.net 00:01:15.35] | `Sts_Vacuum_Rerun` | Bool | Ok | Ok | yes |
|
||||
[xUnit.net 00:01:15.35] | `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | yes |
|
||||
[xUnit.net 00:01:15.35] | `Local:2:I.RunMode` | Bool | Ok | Ok | yes |
|
||||
[xUnit.net 00:01:15.35] | `Ons_VacuumRerun` | Bool | Ok | Ok | yes |
|
||||
[xUnit.net 00:01:15.35] | `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | yes |
|
||||
[xUnit.net 00:01:15.35]
|
||||
[xUnit.net 00:01:15.35] Unexpected divergences: 2; expected divergences: 0.
|
||||
[xUnit.net 00:01:15.35] - UNEXPECTED: WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)
|
||||
[xUnit.net 00:01:15.35] - UNEXPECTED: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])
|
||||
[xUnit.net 00:01:15.35] Finished: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
Process terminated.
|
||||
A callback was made on a garbage collected delegate of type 'libplctag.NativeImport!libplctag.NativeImport.plctag+callback_func_ex::Invoke'.
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [31 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
|
||||
Standard Output Messages:
|
||||
## Browse — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
|
||||
libplctag: 3162 tag(s); native: 3162 tag(s)
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
|
||||
Standard Output Messages:
|
||||
## Templates — 20 id(s); truncated by AB_DIFF_MAX_TEMPLATES: libplctag 74, native 74
|
||||
| Template | libplctag | native | Equal? |
|
||||
|---|---|---|---|
|
||||
| 283 | Recipe (5 members, 1680 B) | Recipe (5 members, 1680 B) | yes |
|
||||
| 295 | UDT_PartStatus (5 members, 4 B) | UDT_PartStatus (5 members, 4 B) | yes |
|
||||
| 344 | Recipe_Clamp (7 members, 420 B) | Recipe_Clamp (7 members, 420 B) | yes |
|
||||
| 491 | ForceSegmentEnableCheck (3 members, 4 B) | ForceSegmentEnableCheck (3 members, 4 B) | yes |
|
||||
| 527 | Recipe_PowerUnit (6 members, 24 B) | Recipe_PowerUnit (6 members, 24 B) | yes |
|
||||
| 622 | UDT_TemperatureProfile_Segment (11 members, 40 B) | UDT_TemperatureProfile_Segment (11 members, 40 B) | yes |
|
||||
| 676 | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | yes |
|
||||
| 700 | AB:5000_AI4CJ:I:0 (12 members, 68 B) | AB:5000_AI4CJ:I:0 (12 members, 68 B) | yes |
|
||||
| 706 | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | yes |
|
||||
| 715 | TwoHand_Control (8 members, 16 B) | TwoHand_Control (8 members, 16 B) | yes |
|
||||
| 752 | STR0016 (2 members, 20 B) | STR0016 (2 members, 20 B) | yes |
|
||||
| 842 | STR0032 (2 members, 36 B) | STR0032 (2 members, 36 B) | yes |
|
||||
| 862 | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | yes |
|
||||
| 1129 | raM_Tec_LimitWithModulo (38 members, 120 B) | raM_Tec_LimitWithModulo (38 members, 120 B) | yes |
|
||||
| 1144 | PartStatus (8 members, 12 B) | PartStatus (8 members, 12 B) | yes |
|
||||
| 1166 | AB:5000_DI16:C:0 (18 members, 72 B) | AB:5000_DI16:C:0 (18 members, 72 B) | yes |
|
||||
| 1192 | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | yes |
|
||||
| 1206 | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | yes |
|
||||
| 1289 | TimeString (12 members, 376 B) | TimeString (12 members, 376 B) | yes |
|
||||
| 1313 | raM_Dvc_5000AO4 (73 members, 164 B) | raM_Dvc_5000AO4 (73 members, 164 B) | yes |
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
|
||||
Standard Output Messages:
|
||||
Native Forward Opens over the run: 1.
|
||||
|
||||
|
||||
Failed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [2 ms]
|
||||
Error Message:
|
||||
Shouldly.ShouldAssertException : unexpected
|
||||
should be empty but had
|
||||
2
|
||||
items and was
|
||||
["WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)", "Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])"]
|
||||
Stack Trace:
|
||||
at ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports() in /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs:line 123
|
||||
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
|
||||
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
|
||||
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
|
||||
--- End of stack trace from previous location ---
|
||||
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
|
||||
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
|
||||
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task, ConfigureAwaitOptions options)
|
||||
Standard Output Messages:
|
||||
## Reads — 25 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 25, max templates 20, pace 50 ms
|
||||
Selection by origin: array 9, udt member 8, scalar 8
|
||||
| Tag | Type | libplctag status | native status | Equal? |
|
||||
|---|---|---|---|---|
|
||||
| `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | yes |
|
||||
| `Local:1:I.InfoBits` | SInt | Ok | Ok | yes |
|
||||
| `__CONTAINER` | DInt | Ok | Ok | yes |
|
||||
| `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | yes |
|
||||
| `Local:1:I.RunMode` | Bool | Ok | Ok | yes |
|
||||
| `WW_M4ActualTemp` | Real | Ok | Ok | **NO: raw** |
|
||||
| `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | yes |
|
||||
| `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | yes |
|
||||
| `WW_P1_Status_Msg` | DInt | Ok | Ok | yes |
|
||||
| `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **NO: raw** |
|
||||
| `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | yes |
|
||||
| `DI_CycleStart_PB` | Bool | Ok | Ok | yes |
|
||||
| `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | yes |
|
||||
| `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | yes |
|
||||
| `test` | DInt | Ok | Ok | yes |
|
||||
| `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | yes |
|
||||
| `Local:1:I.Pad1` | Int | NotFound | NotFound | yes |
|
||||
| `UN01_EventsPTR` | DInt | Ok | Ok | yes |
|
||||
| `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | yes |
|
||||
| `Local:2:I.InfoBits` | SInt | Ok | Ok | yes |
|
||||
| `Sts_Vacuum_Rerun` | Bool | Ok | Ok | yes |
|
||||
| `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | yes |
|
||||
| `Local:2:I.RunMode` | Bool | Ok | Ok | yes |
|
||||
| `Ons_VacuumRerun` | Bool | Ok | Ok | yes |
|
||||
| `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | yes |
|
||||
|
||||
Unexpected divergences: 2; expected divergences: 0.
|
||||
- UNEXPECTED: WW_M4ActualTemp: **NO: raw** (lib 4 byte(s) 8471C043 = 384.88684; native 4 byte(s) A491C043 = 385.13782)
|
||||
- UNEXPECTED: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5: **NO: raw** (lib 20 byte(s) 0050C3481CE387401CE3A740E41CD8C01CE38740 = [400000,4.2464733,5.2464733,-6.7535267,4.2464733]; native 20 byte(s) 0050C34811DD874011DDA740EF22D8C011DD8740 = [400000,4.2457356,5.2457356,-6.7542644,4.2457356])
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
|
||||
Standard Output Messages:
|
||||
TCP connections to the target: baseline 0, before native 0, libplctag drain wait 5284 ms.
|
||||
|
||||
|
||||
|
||||
Test Run Failed.
|
||||
Total tests: 14
|
||||
Passed: 12
|
||||
Failed: 2
|
||||
Total time: 1.2601 Minutes
|
||||
@@ -0,0 +1,369 @@
|
||||
|
||||
Test run for /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll (.NETCoreApp,Version=v10.0)
|
||||
VSTest version 18.0.1 (arm64)
|
||||
|
||||
Starting test execution, please wait...
|
||||
A total of 1 test files matched the specified pattern.
|
||||
/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll
|
||||
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.0.2+dd36e86129 (64-bit .NET 10.0.5)
|
||||
[xUnit.net 00:00:00.46] Discovering: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
[xUnit.net 00:00:00.70] Discovered: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
[xUnit.net 00:00:00.93] Starting: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [26 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [17 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [4 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
|
||||
Standard Output Messages:
|
||||
libplctag connects before the abort: 1
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [15 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [3 m 2 s]
|
||||
Standard Output Messages:
|
||||
## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
|
||||
Tags per pass: 95 (100 distinct); passes per transport: 50; gap between passes: 200 ms.
|
||||
Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
|
||||
Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
|
||||
Native requests spent on setup (one read per tag): 95; native Forward Opens: 1; native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
|
||||
libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
|
||||
|
||||
| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| libplctag | 50 | 107.0 | 139.5 | 57.9 | 164.8 | n/a | 0 |
|
||||
| native | 50 | 100.3 | 105.8 | 94.8 | 107.5 | 2 | 0 |
|
||||
|
||||
Criterion: native p95 105.8 ms ≤ libplctag p95 139.5 ms × 1.10 = 153.4 ms → PASS
|
||||
|
||||
| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | 104.8 | 0/0/0 | 100.3 | 0/0/0 | 2 | 44.9/49.6/54.4 |
|
||||
| 2 | 57.9 | 0/0/0 | 100.5 | 0/0/0 | 2 | 47.0/50.1/53.3 |
|
||||
| 3 | 139.5 | 0/0/0 | 105.8 | 0/0/0 | 2 | 45.3/52.9/60.4 |
|
||||
| 4 | 104.5 | 0/0/0 | 100.6 | 1/0/0 | 2 | 47.3/50.2/53.2 |
|
||||
| 5 | 108.0 | 0/0/0 | 105.3 | 0/0/0 | 2 | 44.5/52.6/60.6 |
|
||||
| 6 | 104.4 | 0/0/0 | 105.6 | 0/0/0 | 2 | 44.2/52.7/61.2 |
|
||||
| 7 | 60.8 | 0/0/0 | 98.7 | 0/0/0 | 2 | 44.3/49.3/54.2 |
|
||||
| 8 | 59.6 | 0/0/0 | 102.6 | 0/0/0 | 2 | 47.4/51.2/55.0 |
|
||||
| 9 | 109.2 | 0/0/0 | 97.0 | 0/0/0 | 2 | 45.9/48.4/50.9 |
|
||||
| 10 | 109.7 | 0/0/0 | 99.3 | 0/0/0 | 2 | 45.9/49.6/53.3 |
|
||||
| 11 | 105.9 | 0/0/0 | 103.0 | 0/0/0 | 2 | 46.2/51.4/56.7 |
|
||||
| 12 | 107.7 | 0/0/0 | 95.1 | 0/0/0 | 2 | 43.3/47.5/51.7 |
|
||||
| 13 | 104.7 | 0/0/0 | 104.0 | 0/0/0 | 2 | 45.7/52.0/58.2 |
|
||||
| 14 | 106.8 | 0/0/0 | 96.5 | 0/0/0 | 2 | 43.6/48.2/52.7 |
|
||||
| 15 | 107.2 | 0/0/0 | 102.4 | 0/0/0 | 2 | 46.1/51.1/56.2 |
|
||||
| 16 | 105.5 | 0/0/0 | 97.4 | 0/0/0 | 2 | 44.7/48.6/52.6 |
|
||||
| 17 | 105.9 | 0/0/0 | 107.5 | 0/0/0 | 2 | 51.0/53.7/56.4 |
|
||||
| 18 | 103.2 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.0/48.2/53.5 |
|
||||
| 19 | 106.5 | 0/0/0 | 102.1 | 0/0/0 | 2 | 45.5/51.0/56.4 |
|
||||
| 20 | 106.8 | 0/0/0 | 99.6 | 0/0/0 | 2 | 44.6/49.7/54.9 |
|
||||
| 21 | 122.8 | 0/0/0 | 104.5 | 0/0/0 | 2 | 52.1/52.2/52.3 |
|
||||
| 22 | 109.8 | 0/0/0 | 104.3 | 0/0/0 | 2 | 45.8/52.1/58.5 |
|
||||
| 23 | 104.3 | 0/0/0 | 98.6 | 0/0/0 | 2 | 45.4/49.2/53.0 |
|
||||
| 24 | 109.7 | 0/0/0 | 98.8 | 0/0/0 | 2 | 45.9/49.3/52.8 |
|
||||
| 25 | 155.1 | 0/0/0 | 101.0 | 0/0/0 | 2 | 45.5/50.4/55.4 |
|
||||
| 26 | 164.8 | 0/0/0 | 102.9 | 0/0/0 | 2 | 43.9/51.4/58.8 |
|
||||
| 27 | 120.4 | 0/0/0 | 101.4 | 0/0/0 | 2 | 48.2/50.7/53.1 |
|
||||
| 28 | 108.6 | 0/0/0 | 101.7 | 0/0/0 | 2 | 46.9/50.8/54.7 |
|
||||
| 29 | 106.0 | 0/0/0 | 98.5 | 0/0/0 | 2 | 45.5/49.2/52.9 |
|
||||
| 30 | 102.9 | 0/0/0 | 94.9 | 0/0/0 | 2 | 43.6/47.4/51.2 |
|
||||
| 31 | 111.3 | 0/0/0 | 99.9 | 0/0/0 | 2 | 45.6/49.9/54.2 |
|
||||
| 32 | 103.3 | 0/0/0 | 96.4 | 0/0/0 | 2 | 43.6/48.1/52.6 |
|
||||
| 33 | 105.8 | 0/0/0 | 104.6 | 0/0/0 | 2 | 46.4/52.2/58.0 |
|
||||
| 34 | 109.3 | 0/0/0 | 95.8 | 0/0/0 | 2 | 43.2/47.8/52.4 |
|
||||
| 35 | 110.2 | 0/0/0 | 102.1 | 0/0/0 | 2 | 46.3/51.0/55.7 |
|
||||
| 36 | 108.0 | 0/0/0 | 101.7 | 0/0/0 | 2 | 47.5/50.8/54.0 |
|
||||
| 37 | 107.6 | 0/0/0 | 96.7 | 0/0/0 | 2 | 45.0/48.3/51.5 |
|
||||
| 38 | 108.9 | 0/0/0 | 96.5 | 0/0/0 | 2 | 44.7/48.2/51.6 |
|
||||
| 39 | 105.2 | 0/0/0 | 98.2 | 0/0/0 | 2 | 45.6/49.0/52.5 |
|
||||
| 40 | 106.7 | 0/0/0 | 99.4 | 0/0/0 | 2 | 44.4/49.6/54.9 |
|
||||
| 41 | 107.0 | 0/0/0 | 101.8 | 0/0/0 | 2 | 46.9/50.8/54.8 |
|
||||
| 42 | 108.6 | 0/0/0 | 106.7 | 0/0/0 | 2 | 49.2/53.3/57.4 |
|
||||
| 43 | 107.0 | 0/0/0 | 102.6 | 0/0/0 | 2 | 49.9/51.2/52.6 |
|
||||
| 44 | 107.7 | 0/0/0 | 100.3 | 0/0/0 | 2 | 47.4/50.1/52.7 |
|
||||
| 45 | 109.5 | 0/0/0 | 102.6 | 0/0/0 | 2 | 45.8/51.2/56.7 |
|
||||
| 46 | 107.6 | 0/0/0 | 104.0 | 0/0/0 | 2 | 48.4/52.0/55.5 |
|
||||
| 47 | 115.6 | 0/0/0 | 99.0 | 0/0/0 | 2 | 45.9/49.4/52.9 |
|
||||
| 48 | 106.0 | 0/0/0 | 97.8 | 0/0/0 | 2 | 44.9/48.8/52.8 |
|
||||
| 49 | 104.5 | 0/0/0 | 98.4 | 0/0/0 | 2 | 46.1/49.1/52.2 |
|
||||
| 50 | 112.5 | 0/0/0 | 94.8 | 1/1/1 | 2 | 43.0/47.3/51.7 |
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_closes_its_one_connection_with_exactly_one_forward_close [< 1 ms]
|
||||
Standard Output Messages:
|
||||
Native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.No_failed_libplctag_tag_is_left_with_a_live_native_callback [< 1 ms]
|
||||
Standard Output Messages:
|
||||
libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.A_live_value_is_compared_structurally_and_a_stable_one_by_value [3 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [36 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
|
||||
Standard Output Messages:
|
||||
## Browse — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
|
||||
libplctag: 3162 tag(s); native: 3162 tag(s)
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
|
||||
Standard Output Messages:
|
||||
## Templates — 20 id(s); truncated by AB_DIFF_MAX_TEMPLATES: libplctag 74, native 74
|
||||
| Template | libplctag | native | Equal? |
|
||||
|---|---|---|---|
|
||||
| 283 | Recipe (5 members, 1680 B) | Recipe (5 members, 1680 B) | yes |
|
||||
| 295 | UDT_PartStatus (5 members, 4 B) | UDT_PartStatus (5 members, 4 B) | yes |
|
||||
| 344 | Recipe_Clamp (7 members, 420 B) | Recipe_Clamp (7 members, 420 B) | yes |
|
||||
| 491 | ForceSegmentEnableCheck (3 members, 4 B) | ForceSegmentEnableCheck (3 members, 4 B) | yes |
|
||||
| 527 | Recipe_PowerUnit (6 members, 24 B) | Recipe_PowerUnit (6 members, 24 B) | yes |
|
||||
| 622 | UDT_TemperatureProfile_Segment (11 members, 40 B) | UDT_TemperatureProfile_Segment (11 members, 40 B) | yes |
|
||||
| 676 | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | yes |
|
||||
| 700 | AB:5000_AI4CJ:I:0 (12 members, 68 B) | AB:5000_AI4CJ:I:0 (12 members, 68 B) | yes |
|
||||
| 706 | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | yes |
|
||||
| 715 | TwoHand_Control (8 members, 16 B) | TwoHand_Control (8 members, 16 B) | yes |
|
||||
| 752 | STR0016 (2 members, 20 B) | STR0016 (2 members, 20 B) | yes |
|
||||
| 842 | STR0032 (2 members, 36 B) | STR0032 (2 members, 36 B) | yes |
|
||||
| 862 | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | yes |
|
||||
| 1129 | raM_Tec_LimitWithModulo (38 members, 120 B) | raM_Tec_LimitWithModulo (38 members, 120 B) | yes |
|
||||
| 1144 | PartStatus (8 members, 12 B) | PartStatus (8 members, 12 B) | yes |
|
||||
| 1166 | AB:5000_DI16:C:0 (18 members, 72 B) | AB:5000_DI16:C:0 (18 members, 72 B) | yes |
|
||||
| 1192 | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | yes |
|
||||
| 1206 | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | yes |
|
||||
| 1289 | TimeString (12 members, 376 B) | TimeString (12 members, 376 B) | yes |
|
||||
| 1313 | raM_Dvc_5000AO4 (73 members, 164 B) | raM_Dvc_5000AO4 (73 members, 164 B) | yes |
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
|
||||
Standard Output Messages:
|
||||
Native Forward Opens over the run: 1.
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [3 ms]
|
||||
Standard Output Messages:
|
||||
## Reads — 200 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
|
||||
Selection by origin: array 20, udt member 20, scalar 160
|
||||
| Tag | Type | libplctag status | native status | libplctag probe | Equal? |
|
||||
|---|---|---|---|---|---|
|
||||
| `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | stable | yes |
|
||||
| `Local:1:I.InfoBits` | SInt | Ok | Ok | stable | yes |
|
||||
| `__CONTAINER` | DInt | Ok | Ok | stable | yes |
|
||||
| `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | stable | yes |
|
||||
| `Local:1:I.RunMode` | Bool | Ok | Ok | stable | yes |
|
||||
| `WW_M4ActualTemp` | Real | Ok | Ok | **live** | live |
|
||||
| `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | stable | yes |
|
||||
| `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
|
||||
| `WW_P1_Status_Msg` | DInt | Ok | Ok | stable | yes |
|
||||
| `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **live** | live |
|
||||
| `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
|
||||
| `DI_CycleStart_PB` | Bool | Ok | Ok | stable | yes |
|
||||
| `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | stable | yes |
|
||||
| `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
|
||||
| `test` | DInt | Ok | Ok | stable | yes |
|
||||
| `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | stable | yes |
|
||||
| `Local:1:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
|
||||
| `UN01_EventsPTR` | DInt | Ok | Ok | stable | yes |
|
||||
| `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
|
||||
| `Local:2:I.InfoBits` | SInt | Ok | Ok | stable | yes |
|
||||
| `Sts_Vacuum_Rerun` | Bool | Ok | Ok | stable | yes |
|
||||
| `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | stable | yes |
|
||||
| `Local:2:I.RunMode` | Bool | Ok | Ok | stable | yes |
|
||||
| `Ons_VacuumRerun` | Bool | Ok | Ok | stable | yes |
|
||||
| `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
|
||||
| `Local:2:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
|
||||
| `test2` | DInt | Ok | Ok | stable | yes |
|
||||
| `Program:EM02_Clamp.__fout79B2713C88B35A28` | Real[3] | Ok | Ok | stable | yes |
|
||||
| `Local:2:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
|
||||
| `_Val_PressurePVx` | Real | Ok | Ok | stable | yes |
|
||||
| `Program:EM02_Clamp.__fout2F67CD35C2302EE0` | Real[4] | Ok | Ok | **live** | live |
|
||||
| `Local:2:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
|
||||
| `C_LimitType_Outside` | DInt | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB` | Real[4] | Ok | Ok | **live** | live |
|
||||
| `Local:2:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
|
||||
| `Par_TargetTemperature_Tolerance` | Real | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_EM01_Zone1.__fout92848E133B44AF24` | Real[8] | Ok | Ok | **live** | live |
|
||||
| `Local:3:I.InfoBits` | SInt | Ok | Ok | stable | yes |
|
||||
| `Cfg_RapidHeatup_Tolerancex` | DInt | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_EM01_Zone1.__fout6DC7745628658A9D` | Real[2] | Ok | Ok | **live** | live |
|
||||
| `Local:3:I.RunMode` | Bool | Ok | Ok | stable | yes |
|
||||
| `Cfg_RapidHeatup_Tolerance` | Real | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_EM01_Zone1.__fout7C6F52924950A71D` | Real[2] | Ok | Ok | stable | yes |
|
||||
| `Local:3:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
|
||||
| `test_stop` | Bool | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_EM01_Zone1.__foutCBB7A04F2668DCD0` | Real[2] | Ok | Ok | stable | yes |
|
||||
| `Local:3:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
|
||||
| `Sts_ScreenLock` | Bool | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_TemperatureControl._DisableStates` | DInt[32] | Ok | Ok | stable | yes |
|
||||
| `Local:3:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
|
||||
| `xSts_ClearResult` | Bool | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_TemperatureControl._ModeNames` | String[32] | Ok | Ok | stable | yes |
|
||||
| `Local:3:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
|
||||
| `WS2_CLOSE_Delay_Setpoint` | Int | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_TemperatureControl._ModeTransitions` | DInt[32] | Ok | Ok | stable | yes |
|
||||
| `Local:5:C.CfgSize` | DInt | NotFound | NotFound | NotFound | yes |
|
||||
| `WS2_Force_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_TemperatureControl._SEQ_UP` | DInt[32] | Ok | Ok | stable | yes |
|
||||
| `Local:5:C.CfgIDNum` | DInt | NotFound | NotFound | NotFound | yes |
|
||||
| `WS2_Force_Red_Setpoint` | Real | Ok | Ok | stable | yes |
|
||||
| `C_LimitType_Inside` | DInt | Ok | Ok | stable | yes |
|
||||
| `xxCfg_WaterValve_MinTime` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_Force_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_Force_Yellow` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WW_Green` | Bool | Ok | Ok | stable | yes |
|
||||
| `WW_Down` | Bool | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_HighMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_LowMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_Mold_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_Mold_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_POSITION_SP` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG1` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG10` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG2` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG3` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG4` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG5` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG6` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG7` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG8` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG9` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_SPEED_SP` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
|
||||
Unexpected divergences: 0; expected divergences: 0; live tags (value moved between the two libplctag reads; compared by status, length, type code, shape and every element that held still): 6 of 200.
|
||||
- live: WW_M4ActualTemp (lib 4 byte(s) 0CCDC243 = 389.60193; lib probe 4 byte(s) 7084C243 = 389.03467; native 4 byte(s) 4C47C243 = 388.557)
|
||||
- live: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5 (lib 20 byte(s) 0050C348218986402189A640DF76D9C021898640 = [400000,4.2042394,5.2042394,-6.7957606,4.2042394]; lib probe 20 byte(s) 0050C348E74B8640E74BA64019B4D9C0E74B8640 = [400000,4.1967654,5.1967654,-6.8032346,4.1967654]; native 20 byte(s) 0050C348BB328640BB32A64045CDD9C0BB328640 = [400000,4.1936927,5.1936927,-6.8063073,4.1936927])
|
||||
- live: Program:EM02_Clamp.__fout2F67CD35C2302EE0 (lib 16 byte(s) BFB2BF379A9919BF00D68349CDCCCCBE = [2.285221E-05,-0.6,1080000,-0.4]; lib probe 16 byte(s) 5119313A9A9919BF00D68349CDCCCCBE = [0.00067557866,-0.6,1080000,-0.4]; native 16 byte(s) 1562C2B99A9919BF00D68349CDCCCCBE = [-0.0003707564,-0.6,1080000,-0.4])
|
||||
- live: Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB (lib 16 byte(s) 00E14CC0CDDA75BF000000C08F2D2BC0 = [-3.201233,-0.9603699,-2,-2.6746557]; lib probe 16 byte(s) 009464C09A2589BF000000C0A84932C0 = [-3.5715332,-1.07146,-2,-2.7857456]; native 16 byte(s) 00A03AC034F35FBF000000C0A8B325C0 = [-2.9160156,-0.87480474,-2,-2.5890903])
|
||||
- live: Program:ES03_EM01_Zone1.__fout92848E133B44AF24 (lib 32 byte(s) 40EDA94160F0D043000090C10022A14980256C41C656D143006A4F400000A8C1 = [21.240845,417.87793,-18,1320000,14.759155,418.67792,3.2408447,-21]; lib probe 32 byte(s) 6017AD412089D043000090C10022A14940D1654186EFD04300BB68400000A8C1 = [21.636414,417.0713,-18,1320000,14.363586,417.87128,3.6364136,-21]; native 32 byte(s) C047A841BE97CF43000090C10022A14980706F4124FECF43003E42400000A8C1 = [21.035034,415.1855,-18,1320000,14.964966,415.98547,3.0350342,-21])
|
||||
- live: Program:ES03_EM01_Zone1.__fout6DC7745628658A9D (lib 8 byte(s) 00824E40CD34A53F = [3.2266846,1.2906739]; lib probe 8 byte(s) 008A6440CDD4B63F = [3.5709229,1.4283692]; native 8 byte(s) 000D3940670A943F = [2.8914185,1.1565675])
|
||||
|
||||
|
||||
[xUnit.net 00:03:12.61] Finished: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
|
||||
Standard Output Messages:
|
||||
TCP connections to the target: baseline 0, before native 0, libplctag drain wait 1 ms.
|
||||
|
||||
|
||||
|
||||
Test Run Successful.
|
||||
Total tests: 17
|
||||
Passed: 17
|
||||
Total time: 3.2143 Minutes
|
||||
@@ -0,0 +1,369 @@
|
||||
|
||||
Test run for /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll (.NETCoreApp,Version=v10.0)
|
||||
VSTest version 18.0.1 (arm64)
|
||||
|
||||
Starting test execution, please wait...
|
||||
A total of 1 test files matched the specified pattern.
|
||||
/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll
|
||||
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.0.2+dd36e86129 (64-bit .NET 10.0.5)
|
||||
[xUnit.net 00:00:00.35] Discovering: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
[xUnit.net 00:00:00.49] Discovered: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
[xUnit.net 00:00:00.60] Starting: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [9 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [7 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [2 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
|
||||
Standard Output Messages:
|
||||
libplctag connects before the abort: 1
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [13 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [2 m 56 s]
|
||||
Standard Output Messages:
|
||||
## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
|
||||
Tags per pass: 95 (100 distinct); passes per transport: 50; gap between passes: 200 ms.
|
||||
Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
|
||||
Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
|
||||
Native requests spent on setup (one read per tag): 95; native Forward Opens: 1; native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
|
||||
libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
|
||||
|
||||
| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| libplctag | 50 | 101.8 | 197.3 | 54.6 | 219.2 | n/a | 0 |
|
||||
| native | 50 | 99.4 | 105.1 | 76.0 | 139.3 | 2 | 0 |
|
||||
|
||||
Criterion: native p95 105.1 ms ≤ libplctag p95 197.3 ms × 1.10 = 217.1 ms → PASS
|
||||
|
||||
| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | 99.9 | 0/0/0 | 99.6 | 0/0/0 | 2 | 45.0/49.4/53.8 |
|
||||
| 2 | 101.7 | 0/0/0 | 101.3 | 0/0/0 | 2 | 48.1/50.5/53.0 |
|
||||
| 3 | 100.0 | 0/0/0 | 99.6 | 0/0/0 | 2 | 45.7/49.7/53.7 |
|
||||
| 4 | 102.4 | 0/0/0 | 98.9 | 1/0/0 | 2 | 48.2/49.4/50.5 |
|
||||
| 5 | 105.8 | 0/0/0 | 97.1 | 0/0/0 | 2 | 44.8/48.5/52.2 |
|
||||
| 6 | 103.5 | 0/0/0 | 99.8 | 0/0/0 | 2 | 45.3/49.8/54.3 |
|
||||
| 7 | 100.5 | 0/0/0 | 105.1 | 0/0/0 | 2 | 51.2/52.5/53.8 |
|
||||
| 8 | 105.7 | 0/0/0 | 94.4 | 0/0/0 | 2 | 43.3/47.1/50.9 |
|
||||
| 9 | 99.8 | 0/0/0 | 99.1 | 0/0/0 | 2 | 47.0/49.5/51.9 |
|
||||
| 10 | 102.8 | 0/0/0 | 98.1 | 0/0/0 | 2 | 48.4/49.0/49.5 |
|
||||
| 11 | 54.6 | 0/0/0 | 98.7 | 0/0/0 | 2 | 44.0/49.3/54.5 |
|
||||
| 12 | 99.8 | 0/0/0 | 105.2 | 0/0/0 | 2 | 44.6/52.5/60.5 |
|
||||
| 13 | 101.5 | 0/0/0 | 94.9 | 0/0/0 | 2 | 43.7/47.4/51.0 |
|
||||
| 14 | 109.8 | 0/0/0 | 98.9 | 0/0/0 | 2 | 46.9/49.4/51.8 |
|
||||
| 15 | 106.7 | 0/0/0 | 96.3 | 0/0/0 | 2 | 43.2/48.0/52.8 |
|
||||
| 16 | 165.0 | 0/0/0 | 99.4 | 0/0/0 | 2 | 46.6/49.6/52.5 |
|
||||
| 17 | 197.3 | 0/0/0 | 101.3 | 0/0/0 | 2 | 45.7/50.6/55.4 |
|
||||
| 18 | 71.0 | 0/0/0 | 98.8 | 0/0/0 | 2 | 45.3/49.3/53.4 |
|
||||
| 19 | 219.2 | 0/0/0 | 101.0 | 0/0/0 | 2 | 48.0/50.4/52.8 |
|
||||
| 20 | 168.8 | 0/0/0 | 100.5 | 0/0/0 | 2 | 46.2/50.1/54.1 |
|
||||
| 21 | 95.3 | 0/0/0 | 98.3 | 0/0/0 | 2 | 45.7/49.0/52.4 |
|
||||
| 22 | 133.9 | 0/0/0 | 98.9 | 0/0/0 | 2 | 46.4/49.4/52.3 |
|
||||
| 23 | 163.0 | 0/0/0 | 97.2 | 0/0/0 | 2 | 44.9/48.5/52.1 |
|
||||
| 24 | 184.5 | 0/0/0 | 101.4 | 0/0/0 | 2 | 49.1/50.6/52.2 |
|
||||
| 25 | 130.9 | 0/0/0 | 97.2 | 0/0/0 | 2 | 43.7/48.5/53.3 |
|
||||
| 26 | 76.8 | 0/0/0 | 103.2 | 0/0/0 | 2 | 47.3/51.5/55.8 |
|
||||
| 27 | 99.6 | 0/0/0 | 76.0 | 0/0/0 | 1 | 75.8/75.8/75.8 |
|
||||
| 28 | 113.8 | 0/0/0 | 100.3 | 0/0/0 | 2 | 46.4/50.1/53.7 |
|
||||
| 29 | 100.9 | 0/0/0 | 96.0 | 0/0/0 | 2 | 44.2/47.9/51.6 |
|
||||
| 30 | 106.0 | 0/0/0 | 103.0 | 0/0/0 | 2 | 49.9/51.4/53.0 |
|
||||
| 31 | 107.3 | 0/0/0 | 103.7 | 0/0/0 | 2 | 47.4/51.8/56.1 |
|
||||
| 32 | 100.9 | 0/0/0 | 102.4 | 0/0/0 | 2 | 47.4/51.1/54.8 |
|
||||
| 33 | 99.9 | 0/0/0 | 98.6 | 0/0/0 | 2 | 46.0/49.2/52.4 |
|
||||
| 34 | 101.4 | 0/0/0 | 98.7 | 0/0/0 | 2 | 46.7/49.3/51.8 |
|
||||
| 35 | 97.8 | 0/0/0 | 98.9 | 0/0/0 | 2 | 46.3/49.4/52.5 |
|
||||
| 36 | 98.7 | 0/0/0 | 98.5 | 0/0/0 | 2 | 45.2/49.2/53.2 |
|
||||
| 37 | 101.2 | 0/0/0 | 100.1 | 0/0/0 | 2 | 45.2/49.9/54.7 |
|
||||
| 38 | 102.6 | 0/0/0 | 100.0 | 0/0/0 | 2 | 44.0/49.9/55.8 |
|
||||
| 39 | 103.5 | 0/0/0 | 97.7 | 0/0/0 | 2 | 45.0/48.8/52.5 |
|
||||
| 40 | 104.5 | 0/0/0 | 99.6 | 0/0/0 | 2 | 46.0/49.7/53.5 |
|
||||
| 41 | 103.0 | 0/0/0 | 99.4 | 0/0/0 | 2 | 45.4/49.6/53.9 |
|
||||
| 42 | 101.8 | 0/0/0 | 98.7 | 0/0/0 | 2 | 46.0/49.2/52.4 |
|
||||
| 43 | 101.8 | 0/0/0 | 139.3 | 0/0/0 | 2 | 45.6/69.6/93.6 |
|
||||
| 44 | 109.4 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.2/48.2/53.3 |
|
||||
| 45 | 101.6 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.2/48.2/53.2 |
|
||||
| 46 | 101.6 | 0/0/0 | 102.8 | 0/0/0 | 2 | 47.9/51.3/54.8 |
|
||||
| 47 | 204.5 | 0/0/0 | 101.0 | 0/0/0 | 2 | 47.3/50.4/53.5 |
|
||||
| 48 | 108.0 | 0/0/0 | 100.0 | 0/0/0 | 2 | 47.4/49.9/52.4 |
|
||||
| 49 | 99.7 | 0/0/0 | 101.3 | 0/0/0 | 2 | 48.3/50.6/52.8 |
|
||||
| 50 | 99.7 | 0/0/0 | 99.4 | 1/1/1 | 2 | 46.0/49.6/53.2 |
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_closes_its_one_connection_with_exactly_one_forward_close [< 1 ms]
|
||||
Standard Output Messages:
|
||||
Native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.No_failed_libplctag_tag_is_left_with_a_live_native_callback [< 1 ms]
|
||||
Standard Output Messages:
|
||||
libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.A_live_value_is_compared_structurally_and_a_stable_one_by_value [3 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [36 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
|
||||
Standard Output Messages:
|
||||
## Browse — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
|
||||
libplctag: 3162 tag(s); native: 3162 tag(s)
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
|
||||
Standard Output Messages:
|
||||
## Templates — 20 id(s); truncated by AB_DIFF_MAX_TEMPLATES: libplctag 74, native 74
|
||||
| Template | libplctag | native | Equal? |
|
||||
|---|---|---|---|
|
||||
| 283 | Recipe (5 members, 1680 B) | Recipe (5 members, 1680 B) | yes |
|
||||
| 295 | UDT_PartStatus (5 members, 4 B) | UDT_PartStatus (5 members, 4 B) | yes |
|
||||
| 344 | Recipe_Clamp (7 members, 420 B) | Recipe_Clamp (7 members, 420 B) | yes |
|
||||
| 491 | ForceSegmentEnableCheck (3 members, 4 B) | ForceSegmentEnableCheck (3 members, 4 B) | yes |
|
||||
| 527 | Recipe_PowerUnit (6 members, 24 B) | Recipe_PowerUnit (6 members, 24 B) | yes |
|
||||
| 622 | UDT_TemperatureProfile_Segment (11 members, 40 B) | UDT_TemperatureProfile_Segment (11 members, 40 B) | yes |
|
||||
| 676 | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | yes |
|
||||
| 700 | AB:5000_AI4CJ:I:0 (12 members, 68 B) | AB:5000_AI4CJ:I:0 (12 members, 68 B) | yes |
|
||||
| 706 | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | yes |
|
||||
| 715 | TwoHand_Control (8 members, 16 B) | TwoHand_Control (8 members, 16 B) | yes |
|
||||
| 752 | STR0016 (2 members, 20 B) | STR0016 (2 members, 20 B) | yes |
|
||||
| 842 | STR0032 (2 members, 36 B) | STR0032 (2 members, 36 B) | yes |
|
||||
| 862 | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | yes |
|
||||
| 1129 | raM_Tec_LimitWithModulo (38 members, 120 B) | raM_Tec_LimitWithModulo (38 members, 120 B) | yes |
|
||||
| 1144 | PartStatus (8 members, 12 B) | PartStatus (8 members, 12 B) | yes |
|
||||
| 1166 | AB:5000_DI16:C:0 (18 members, 72 B) | AB:5000_DI16:C:0 (18 members, 72 B) | yes |
|
||||
| 1192 | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | yes |
|
||||
| 1206 | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | yes |
|
||||
| 1289 | TimeString (12 members, 376 B) | TimeString (12 members, 376 B) | yes |
|
||||
| 1313 | raM_Dvc_5000AO4 (73 members, 164 B) | raM_Dvc_5000AO4 (73 members, 164 B) | yes |
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
|
||||
Standard Output Messages:
|
||||
Native Forward Opens over the run: 1.
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [3 ms]
|
||||
Standard Output Messages:
|
||||
## Reads — 200 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
|
||||
Selection by origin: array 20, udt member 20, scalar 160
|
||||
| Tag | Type | libplctag status | native status | libplctag probe | Equal? |
|
||||
|---|---|---|---|---|---|
|
||||
| `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | stable | yes |
|
||||
| `Local:1:I.InfoBits` | SInt | Ok | Ok | stable | yes |
|
||||
| `__CONTAINER` | DInt | Ok | Ok | stable | yes |
|
||||
| `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | stable | yes |
|
||||
| `Local:1:I.RunMode` | Bool | Ok | Ok | stable | yes |
|
||||
| `WW_M4ActualTemp` | Real | Ok | Ok | **live** | live |
|
||||
| `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | stable | yes |
|
||||
| `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
|
||||
| `WW_P1_Status_Msg` | DInt | Ok | Ok | stable | yes |
|
||||
| `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **live** | live |
|
||||
| `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
|
||||
| `DI_CycleStart_PB` | Bool | Ok | Ok | stable | yes |
|
||||
| `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | stable | yes |
|
||||
| `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
|
||||
| `test` | DInt | Ok | Ok | stable | yes |
|
||||
| `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | stable | yes |
|
||||
| `Local:1:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
|
||||
| `UN01_EventsPTR` | DInt | Ok | Ok | stable | yes |
|
||||
| `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
|
||||
| `Local:2:I.InfoBits` | SInt | Ok | Ok | stable | yes |
|
||||
| `Sts_Vacuum_Rerun` | Bool | Ok | Ok | stable | yes |
|
||||
| `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | stable | yes |
|
||||
| `Local:2:I.RunMode` | Bool | Ok | Ok | stable | yes |
|
||||
| `Ons_VacuumRerun` | Bool | Ok | Ok | stable | yes |
|
||||
| `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
|
||||
| `Local:2:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
|
||||
| `test2` | DInt | Ok | Ok | stable | yes |
|
||||
| `Program:EM02_Clamp.__fout79B2713C88B35A28` | Real[3] | Ok | Ok | stable | yes |
|
||||
| `Local:2:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
|
||||
| `_Val_PressurePVx` | Real | Ok | Ok | stable | yes |
|
||||
| `Program:EM02_Clamp.__fout2F67CD35C2302EE0` | Real[4] | Ok | Ok | **live** | live |
|
||||
| `Local:2:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
|
||||
| `C_LimitType_Outside` | DInt | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB` | Real[4] | Ok | Ok | **live** | live |
|
||||
| `Local:2:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
|
||||
| `Par_TargetTemperature_Tolerance` | Real | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_EM01_Zone1.__fout92848E133B44AF24` | Real[8] | Ok | Ok | **live** | live |
|
||||
| `Local:3:I.InfoBits` | SInt | Ok | Ok | stable | yes |
|
||||
| `Cfg_RapidHeatup_Tolerancex` | DInt | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_EM01_Zone1.__fout6DC7745628658A9D` | Real[2] | Ok | Ok | **live** | live |
|
||||
| `Local:3:I.RunMode` | Bool | Ok | Ok | stable | yes |
|
||||
| `Cfg_RapidHeatup_Tolerance` | Real | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_EM01_Zone1.__fout7C6F52924950A71D` | Real[2] | Ok | Ok | stable | yes |
|
||||
| `Local:3:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
|
||||
| `test_stop` | Bool | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_EM01_Zone1.__foutCBB7A04F2668DCD0` | Real[2] | Ok | Ok | stable | yes |
|
||||
| `Local:3:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
|
||||
| `Sts_ScreenLock` | Bool | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_TemperatureControl._DisableStates` | DInt[32] | Ok | Ok | stable | yes |
|
||||
| `Local:3:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
|
||||
| `xSts_ClearResult` | Bool | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_TemperatureControl._ModeNames` | String[32] | Ok | Ok | stable | yes |
|
||||
| `Local:3:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
|
||||
| `WS2_CLOSE_Delay_Setpoint` | Int | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_TemperatureControl._ModeTransitions` | DInt[32] | Ok | Ok | stable | yes |
|
||||
| `Local:5:C.CfgSize` | DInt | NotFound | NotFound | NotFound | yes |
|
||||
| `WS2_Force_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_TemperatureControl._SEQ_UP` | DInt[32] | Ok | Ok | stable | yes |
|
||||
| `Local:5:C.CfgIDNum` | DInt | NotFound | NotFound | NotFound | yes |
|
||||
| `WS2_Force_Red_Setpoint` | Real | Ok | Ok | stable | yes |
|
||||
| `C_LimitType_Inside` | DInt | Ok | Ok | stable | yes |
|
||||
| `xxCfg_WaterValve_MinTime` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_Force_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_Force_Yellow` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WW_Green` | Bool | Ok | Ok | stable | yes |
|
||||
| `WW_Down` | Bool | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_HighMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_LowMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_Mold_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_Mold_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_POSITION_SP` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG1` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG10` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG2` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG3` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG4` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG5` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG6` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG7` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG8` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG9` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_SPEED_SP` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
|
||||
Unexpected divergences: 0; expected divergences: 0; live tags (value moved between the two libplctag reads; compared by status, length, type code, shape and every element that held still): 6 of 200.
|
||||
- live: WW_M4ActualTemp (lib 4 byte(s) 20526943 = 233.3208; lib probe 4 byte(s) 908D6843 = 232.55298; native 4 byte(s) 501F6743 = 231.12231)
|
||||
- live: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5 (lib 20 byte(s) 0050C3487C144C403E0A8640C2F5F9C07C144C40 = [400000,3.1887503,4.1887503,-7.8112497,3.1887503]; lib probe 20 byte(s) 0050C3481C244A400E128540F2EDFAC01C244A40 = [400000,3.158454,4.158454,-7.841546,3.158454]; native 20 byte(s) 0050C34831AA454018D58240E82AFDC031AA4540 = [400000,3.0885127,4.0885124,-7.9114876,3.0885127])
|
||||
- live: Program:EM02_Clamp.__fout2F67CD35C2302EE0 (lib 16 byte(s) 96D41EB99A9919BF00D68349CDCCCCBE = [-0.00015147249,-0.6,1080000,-0.4]; lib probe 16 byte(s) A830FE389A9919BF00D68349CDCCCCBE = [0.00012120727,-0.6,1080000,-0.4]; native 16 byte(s) D34D8FB99A9919BF00D68349CDCCCCBE = [-0.0002733307,-0.6,1080000,-0.4])
|
||||
- live: Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB (lib 16 byte(s) 11E416C300000000000000C000000000 = [-150.89088,0,-2,0]; lib probe 16 byte(s) F1E910C300000000000000C000000000 = [-144.91383,0,-2,0]; native 16 byte(s) 194008C300000000000000C000000000 = [-136.25038,0,-2,0])
|
||||
- live: Program:ES03_EM01_Zone1.__fout92848E133B44AF24 (lib 32 byte(s) 91D52843C4085243000090C10022A14991D504C391D5524391D516430000A8C1 = [168.83424,210.03424,-18,1320000,-132.83424,210.83424,150.83424,-21]; lib probe 32 byte(s) CDD7224398074C43000090C10022A1499AAFFDC265D44C43CDD710430000A8C1 = [162.84297,204.02966,-18,1320000,-126.84297,204.82967,144.84297,-21]; native 32 byte(s) 2D311A4388634343000090C10022A1495A62ECC2553044432D3108430000A8C1 = [154.1921,195.3888,-18,1320000,-118.19209,196.1888,136.1921,-21])
|
||||
- live: Program:ES03_EM01_Zone1.__fout6DC7745628658A9D (lib 8 byte(s) 4DC71643E23E7142 = [150.77852,60.31141]; lib probe 8 byte(s) F5BA104388916742 = [144.7303,57.89212]; native 8 byte(s) 650B0843D5AB5942 = [136.04451,54.417805])
|
||||
|
||||
|
||||
[xUnit.net 00:03:06.03] Finished: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
|
||||
Standard Output Messages:
|
||||
TCP connections to the target: baseline 0, before native 0, libplctag drain wait 1 ms.
|
||||
|
||||
|
||||
|
||||
Test Run Successful.
|
||||
Total tests: 17
|
||||
Passed: 17
|
||||
Total time: 3.1046 Minutes
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
# Golden-frame capture recipe for the native AB transport (Task 4 of
|
||||
# docs/plans/2026-10-07-native-ab-transport.md).
|
||||
#
|
||||
# Captures the EtherNet/IP conversation between libplctag (the libplctag 1.5.2
|
||||
# NuGet wrapper, native core 2.6.0) and the ab_server simulator fixtures, and
|
||||
# converts each pcap into the `.hex` line format consumed by
|
||||
# tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures:
|
||||
#
|
||||
# > <client->server EIP frame, uppercase hex, no spaces>
|
||||
# < <server->client EIP frame>
|
||||
# # comment
|
||||
#
|
||||
# Packets are captured INSIDE the simulator container's network namespace
|
||||
# (netshoot sidecar), where ab_server always listens on 44818 — so the same
|
||||
# filter works for the AbLegacy containers whose host ports are 44819-44821.
|
||||
# tshark also runs in the netshoot image on the docker host, so nothing needs
|
||||
# installing on the workstation beyond python3.
|
||||
#
|
||||
# Usage:
|
||||
# CAPDIR=<fresh empty dir> scripts/ab-capture/capture.sh run <container> <name> -- <client command...>
|
||||
# scripts/ab-capture/capture.sh tohex <pcap> <out.hex>
|
||||
# scripts/ab-capture/capture.sh client <client args...>
|
||||
#
|
||||
# `client` builds (once) and runs scripts/ab-capture/client — the console program
|
||||
# over libplctag 1.5.2 that took the committed fixtures: one tag, one Initialize +
|
||||
# Read/Write, then Dispose + plc_tag_shutdown so the Forward Close reaches the wire.
|
||||
# No UnregisterSession is ever sent; the session ends with a TCP RST. Example:
|
||||
#
|
||||
# CAPDIR=$PWD/caps scripts/ab-capture/capture.sh run otopcua-ab-server-controllogix logix-read-dint -- \
|
||||
# scripts/ab-capture/capture.sh client read 10.100.0.35:44818 1,0 ControlLogix TestDINT dint
|
||||
#
|
||||
# See Captures/README.md for every invocation used.
|
||||
#
|
||||
# Fixtures (one AbCip profile at a time — they all bind host :44818):
|
||||
# ssh dohertj2@10.100.0.35 'cd /opt/otopcua-abcip && docker compose --profile controllogix up -d'
|
||||
# ssh dohertj2@10.100.0.35 'cd ~/otopcua-ablegacy && docker compose --profile slc500 up -d'
|
||||
set -euo pipefail
|
||||
|
||||
DOCKER_HOST_SSH="${DOCKER_HOST_SSH:-dohertj2@10.100.0.35}"
|
||||
NETSHOOT="${NETSHOOT:-nicolaka/netshoot:v0.13}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/tmp/abcap}"
|
||||
|
||||
cmd="${1:-}"; shift || true
|
||||
|
||||
case "$cmd" in
|
||||
client)
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
dll="$here/client/bin/Release/net10.0/ab-capture-client.dll"
|
||||
[[ -f "$dll" ]] || dotnet build "$here/client/ab-capture-client.csproj" -c Release -v q >&2
|
||||
exec dotnet "$dll" "$@"
|
||||
;;
|
||||
|
||||
run)
|
||||
container="$1"; name="$2"; shift 2
|
||||
[[ "${1:-}" == "--" ]] && shift
|
||||
: "${CAPDIR:?set CAPDIR to a fresh empty directory}"
|
||||
sidecar="abcap-$name"
|
||||
ssh "$DOCKER_HOST_SSH" "mkdir -p $REMOTE_DIR && rm -f $REMOTE_DIR/$name.pcap && \
|
||||
docker run -d --rm --name $sidecar --net=container:$container -v $REMOTE_DIR:/cap $NETSHOOT \
|
||||
tcpdump -i eth0 -U -w /cap/$name.pcap tcp port 44818 >/dev/null"
|
||||
sleep 2 # let tcpdump open the interface before the client connects
|
||||
"$@" || echo "client exited non-zero: $?" >&2
|
||||
sleep 1
|
||||
ssh "$DOCKER_HOST_SSH" "docker stop $sidecar >/dev/null"
|
||||
scp -q "$DOCKER_HOST_SSH:$REMOTE_DIR/$name.pcap" "$CAPDIR/$name.pcap"
|
||||
echo "$CAPDIR/$name.pcap"
|
||||
;;
|
||||
|
||||
tohex)
|
||||
pcap="$1"; out="$2"
|
||||
base="$(basename "$pcap")"
|
||||
ssh "$DOCKER_HOST_SSH" "mkdir -p $REMOTE_DIR"
|
||||
scp -q "$pcap" "$DOCKER_HOST_SSH:$REMOTE_DIR/tohex-$base"
|
||||
# srcport + payload of every data-bearing, non-retransmitted segment, in capture order.
|
||||
ssh "$DOCKER_HOST_SSH" "docker run --rm -v $REMOTE_DIR:/cap $NETSHOOT \
|
||||
tshark -r /cap/tohex-$base -T fields -e tcp.srcport -e tcp.payload \
|
||||
-Y 'tcp.len>0 && !tcp.analysis.retransmission'" \
|
||||
| python3 -I -c '
|
||||
import sys
|
||||
# Re-join TCP segments into EIP encapsulation frames per direction, using the
|
||||
# 24-byte header length field (offset 2, UInt16 LE) to find frame boundaries.
|
||||
bufs = {True: b"", False: b""}
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
port, payload = line.split("\t")
|
||||
from_client = port != "44818" # srcport 44818 => server->client
|
||||
bufs[from_client] += bytes.fromhex(payload.replace(":", ""))
|
||||
b = bufs[from_client]
|
||||
while len(b) >= 24:
|
||||
total = 24 + int.from_bytes(b[2:4], "little")
|
||||
if len(b) < total:
|
||||
break
|
||||
print(("> " if from_client else "< ") + b[:total].hex().upper())
|
||||
b = b[total:]
|
||||
bufs[from_client] = b
|
||||
for k, rest in bufs.items():
|
||||
if rest:
|
||||
sys.exit(f"trailing partial frame ({len(rest)} bytes, client={k})")
|
||||
' > "$out"
|
||||
echo "$out: $(grep -c '^[<>]' "$out") frames"
|
||||
;;
|
||||
|
||||
*)
|
||||
sed -n '2,37p' "$0"; exit 2 ;;
|
||||
esac
|
||||
@@ -0,0 +1,169 @@
|
||||
// Capture client for scripts/ab-capture/capture.sh.
|
||||
//
|
||||
// One libplctag tag, one operation, then Dispose + plc_tag_shutdown so the Forward Close
|
||||
// reaches the wire. The tag is built with exactly the properties the drivers set
|
||||
// (LibplctagTagRuntime / LibplctagLegacyTagRuntime / LibplctagTagEnumerator): Gateway, Path,
|
||||
// PlcType, Protocol=ab_eip, Name, Timeout, and ElementCount when > 1. Nothing else.
|
||||
//
|
||||
// usage:
|
||||
// ab-capture-client version
|
||||
// ab-capture-client read <host:port> <path|-> <plc> <name> <type> [count]
|
||||
// ab-capture-client write <host:port> <path|-> <plc> <name> <type> <count> <value>
|
||||
//
|
||||
// <plc> a libplctag.PlcType name: ControlLogix, Micro800, Slc500, MicroLogix, Plc5, LogixPccc
|
||||
// <type> dint | int | real | string | bool | raw (read only: hex dump of the buffer)
|
||||
// dintseq (write only): element i (0-based) = ((i+1) << 24) | (i+1), for <count> elements;
|
||||
// <value> is ignored but required.
|
||||
// env UNCONNECTED=1 sets UseConnectedMessaging=false (ignored by libplctag 2.6.0 for Micro800).
|
||||
using libplctag;
|
||||
using libplctag.NativeImport;
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
Console.Error.WriteLine("usage: see the header of Program.cs");
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (args[0] == "seed-bool")
|
||||
{
|
||||
// ab_server rejects libplctag's BOOL write with CIP 0x03: libplctag sends two data bytes for a
|
||||
// one-byte BOOL and ab_server's "too much data" check refuses it. This seeds a BOOL for a
|
||||
// later libplctag READ capture with one raw UCMM Write Tag carrying exactly one data byte.
|
||||
// usage: seed-bool <host> <port> <tag> <0|1> (route is port 1 / slot 0)
|
||||
return await SeedBoolAsync(args[1], int.Parse(args[2]), args[3], args[4] == "1" ? (byte)1 : (byte)0);
|
||||
}
|
||||
|
||||
if (args[0] == "version")
|
||||
{
|
||||
Console.WriteLine($"{plctag.plc_tag_get_int_attribute(0, "version_major", -1)}." +
|
||||
$"{plctag.plc_tag_get_int_attribute(0, "version_minor", -1)}." +
|
||||
$"{plctag.plc_tag_get_int_attribute(0, "version_patch", -1)}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var op = args[0];
|
||||
var gateway = args[1];
|
||||
var path = args[2] == "-" ? null : args[2];
|
||||
var plc = Enum.Parse<PlcType>(args[3], ignoreCase: true);
|
||||
var name = args[4];
|
||||
var type = args[5];
|
||||
var count = args.Length > 6 ? int.Parse(args[6]) : 1;
|
||||
|
||||
var tag = new Tag
|
||||
{
|
||||
Gateway = gateway,
|
||||
Path = path,
|
||||
PlcType = plc,
|
||||
Protocol = Protocol.ab_eip,
|
||||
Name = name,
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
};
|
||||
if (count > 1) tag.ElementCount = count;
|
||||
if (Environment.GetEnvironmentVariable("UNCONNECTED") == "1") tag.UseConnectedMessaging = false;
|
||||
|
||||
var exit = 0;
|
||||
try
|
||||
{
|
||||
await tag.InitializeAsync();
|
||||
if (op == "write")
|
||||
{
|
||||
var value = args[7];
|
||||
switch (type)
|
||||
{
|
||||
case "dint": tag.SetInt32(0, int.Parse(value)); break;
|
||||
case "int": tag.SetInt16(0, short.Parse(value)); break;
|
||||
case "real": tag.SetFloat32(0, float.Parse(value)); break;
|
||||
case "string": tag.SetString(0, value); break;
|
||||
// As LibplctagTagRuntime does: a whole-byte write (SetBit on a Logix BOOL is ErrorBadParam).
|
||||
case "bool": tag.SetInt8(0, value is "1" or "true" ? (sbyte)1 : (sbyte)0); break;
|
||||
case "dintseq":
|
||||
for (var i = 0; i < count; i++) tag.SetInt32(i * 4, ((i + 1) << 24) | (i + 1));
|
||||
break;
|
||||
default: throw new ArgumentException($"unknown write type {type}");
|
||||
}
|
||||
await tag.WriteAsync();
|
||||
Console.WriteLine("write ok");
|
||||
}
|
||||
else
|
||||
{
|
||||
await tag.ReadAsync();
|
||||
Console.WriteLine($"read ok size={tag.GetSize()}");
|
||||
Console.WriteLine(type switch
|
||||
{
|
||||
"dint" => tag.GetInt32(0).ToString(),
|
||||
"int" => tag.GetInt16(0).ToString(),
|
||||
"real" => tag.GetFloat32(0).ToString(),
|
||||
"string" => tag.GetString(0),
|
||||
"bool" => tag.GetBit(0).ToString(),
|
||||
_ => Convert.ToHexString(tag.GetBuffer()),
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"EXC {ex.GetType().Name}: {ex.Message}");
|
||||
exit = 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
tag.Dispose();
|
||||
}
|
||||
|
||||
plctag.plc_tag_shutdown();
|
||||
await Task.Delay(500); // let the Forward Close flush before the process exits
|
||||
return exit;
|
||||
|
||||
static async Task<int> SeedBoolAsync(string host, int port, string tagName, byte value)
|
||||
{
|
||||
using var tcp = new System.Net.Sockets.TcpClient();
|
||||
await tcp.ConnectAsync(host, port);
|
||||
var s = tcp.GetStream();
|
||||
|
||||
// RegisterSession
|
||||
var reg = new byte[28];
|
||||
reg[0] = 0x65; reg[2] = 4; reg[24] = 1;
|
||||
await s.WriteAsync(reg);
|
||||
var regReply = await ReadFrameAsync(s);
|
||||
var session = BitConverter.ToUInt32(regReply, 4);
|
||||
|
||||
// Write Tag (0x4D): ANSI symbol segment, type BOOL 0xC1, count 1, ONE data byte.
|
||||
var name = System.Text.Encoding.ASCII.GetBytes(tagName);
|
||||
var sym = new List<byte> { 0x91, (byte)name.Length };
|
||||
sym.AddRange(name);
|
||||
if (sym.Count % 2 == 1) sym.Add(0);
|
||||
var write = new List<byte> { 0x4D, (byte)(sym.Count / 2) };
|
||||
write.AddRange(sym);
|
||||
write.AddRange([0xC1, 0x00, 0x01, 0x00, value]);
|
||||
|
||||
// Unconnected Send (0x52) to the Connection Manager, routed to port 1 / slot 0.
|
||||
var ucmm = new List<byte> { 0x52, 0x02, 0x20, 0x06, 0x24, 0x01, 0x0A, 0x05 };
|
||||
ucmm.AddRange(BitConverter.GetBytes((ushort)write.Count));
|
||||
ucmm.AddRange(write);
|
||||
if (write.Count % 2 == 1) ucmm.Add(0);
|
||||
ucmm.AddRange([0x01, 0x00, 0x01, 0x00]);
|
||||
|
||||
// SendRRData: interface handle, timeout, 2 items (null address, unconnected data).
|
||||
var data = new List<byte> { 0, 0, 0, 0, 0x0A, 0, 2, 0, 0, 0, 0, 0, 0xB2, 0 };
|
||||
data.AddRange(BitConverter.GetBytes((ushort)ucmm.Count));
|
||||
data.AddRange(ucmm);
|
||||
var frame = new byte[24 + data.Count];
|
||||
frame[0] = 0x6F;
|
||||
BitConverter.GetBytes((ushort)data.Count).CopyTo(frame, 2);
|
||||
BitConverter.GetBytes(session).CopyTo(frame, 4);
|
||||
data.CopyTo(frame, 24);
|
||||
await s.WriteAsync(frame);
|
||||
|
||||
var reply = await ReadFrameAsync(s);
|
||||
var cip = reply.AsSpan(24 + 16).ToArray(); // after handle, timeout, count, null item, data item header
|
||||
Console.WriteLine($"seed-bool reply CIP: {Convert.ToHexString(cip)}");
|
||||
return cip.Length >= 3 && cip[0] == 0xCD && cip[2] == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
static async Task<byte[]> ReadFrameAsync(System.Net.Sockets.NetworkStream s)
|
||||
{
|
||||
var header = new byte[24];
|
||||
await s.ReadExactlyAsync(header);
|
||||
var body = new byte[BitConverter.ToUInt16(header, 2)];
|
||||
await s.ReadExactlyAsync(body);
|
||||
return [.. header, .. body];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!--
|
||||
Capture client for scripts/ab-capture/capture.sh — drives libplctag 1.5.2 against the ab_server
|
||||
fixtures so the golden frames in tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures
|
||||
can be re-taken. Deliberately NOT in ZB.MOM.WW.OtOpcUa.slnx. Pinned to the exact libplctag the
|
||||
drivers use; CPM is switched off so the pin lives here and cannot drift with Directory.Packages.props.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="libplctag" Version="1.5.2" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// The CLI's <c>--transport</c> choice. <see cref="Default"/> leaves
|
||||
/// <see cref="AbCipDriverOptions.Transport"/> null so the driver resolves it through
|
||||
/// <see cref="AbCipTransportResolver"/> exactly as an absent <c>transport</c> key in a deployed config does.
|
||||
/// </summary>
|
||||
public enum AbCipCliTransport
|
||||
{
|
||||
/// <summary>Use the driver's default (resolved by <see cref="AbCipTransportResolver"/>).</summary>
|
||||
Default,
|
||||
|
||||
/// <summary>The managed native transport.</summary>
|
||||
Native,
|
||||
|
||||
/// <summary>The libplctag fallback transport.</summary>
|
||||
Libplctag,
|
||||
}
|
||||
|
||||
/// <summary>Maps a <see cref="AbCipCliTransport"/> onto the driver option.</summary>
|
||||
public static class AbCipCliTransportExtensions
|
||||
{
|
||||
/// <summary>Returns the configured transport: null for <see cref="AbCipCliTransport.Default"/>.</summary>
|
||||
/// <param name="choice">The CLI choice.</param>
|
||||
/// <returns>The value for <see cref="AbCipDriverOptions.Transport"/>.</returns>
|
||||
public static AbCipTransport? ToConfigured(this AbCipCliTransport choice) => choice switch
|
||||
{
|
||||
AbCipCliTransport.Native => AbCipTransport.Native,
|
||||
AbCipCliTransport.Libplctag => AbCipTransport.Libplctag,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -26,6 +26,11 @@ public abstract class AbCipCommandBase : DriverCommandBase
|
||||
"ControlLogix / CompactLogix / Micro800 / GuardLogix (default ControlLogix).")]
|
||||
public AbCipPlcFamily Family { get; init; } = AbCipPlcFamily.ControlLogix;
|
||||
|
||||
/// <summary>Gets the transport stack used to reach the PLC.</summary>
|
||||
[CommandOption("transport", Description =
|
||||
"Transport stack: default (= native), native (managed EtherNet/IP + CIP) or libplctag (fallback).")]
|
||||
public AbCipCliTransport Transport { get; init; } = AbCipCliTransport.Default;
|
||||
|
||||
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
|
||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||
public int TimeoutMs { get; init; } = 5000;
|
||||
@@ -68,6 +73,7 @@ public abstract class AbCipCommandBase : DriverCommandBase
|
||||
WriteIdempotent: t.WriteIdempotent,
|
||||
DeviceName: t.DeviceHostAddress))],
|
||||
Timeout = Timeout,
|
||||
Transport = Transport.ToConfigured(),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
EnableControllerBrowse = false,
|
||||
EnableAlarmProjection = false,
|
||||
|
||||
@@ -6,6 +6,6 @@ return await new CliApplicationBuilder()
|
||||
.SetDescription(
|
||||
"OtOpcUa AB CIP test-client — ad-hoc probe + Logix symbolic reads/writes + polled " +
|
||||
"subscriptions against ControlLogix / CompactLogix / Micro800 / GuardLogix families " +
|
||||
"via libplctag. Second of six driver CLIs; mirrors otopcua-modbus-cli's shape.")
|
||||
"via libplctag or the native transport. Second of six driver CLIs; mirrors otopcua-modbus-cli's shape.")
|
||||
.Build()
|
||||
.RunAsync(args);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// The CLI's <c>--transport</c> choice. <see cref="Default"/> leaves
|
||||
/// <see cref="AbLegacyDriverOptions.Transport"/> null so the driver resolves it through
|
||||
/// <see cref="AbLegacyTransportResolver"/> exactly as an absent <c>transport</c> key in a deployed config does.
|
||||
/// </summary>
|
||||
public enum AbLegacyCliTransport
|
||||
{
|
||||
/// <summary>Use the driver's default (resolved by <see cref="AbLegacyTransportResolver"/>).</summary>
|
||||
Default,
|
||||
|
||||
/// <summary>The managed native transport.</summary>
|
||||
Native,
|
||||
|
||||
/// <summary>The libplctag fallback transport.</summary>
|
||||
Libplctag,
|
||||
}
|
||||
|
||||
/// <summary>Maps a <see cref="AbLegacyCliTransport"/> onto the driver option.</summary>
|
||||
public static class AbLegacyCliTransportExtensions
|
||||
{
|
||||
/// <summary>Returns the configured transport: null for <see cref="AbLegacyCliTransport.Default"/>.</summary>
|
||||
/// <param name="choice">The CLI choice.</param>
|
||||
/// <returns>The value for <see cref="AbLegacyDriverOptions.Transport"/>.</returns>
|
||||
public static AbLegacyTransport? ToConfigured(this AbLegacyCliTransport choice) => choice switch
|
||||
{
|
||||
AbLegacyCliTransport.Native => AbLegacyTransport.Native,
|
||||
AbLegacyCliTransport.Libplctag => AbLegacyTransport.Libplctag,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,13 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
|
||||
"Slc500 / MicroLogix / Plc5 / LogixPccc (default Slc500).")]
|
||||
public AbLegacyPlcFamily PlcType { get; init; } = AbLegacyPlcFamily.Slc500;
|
||||
|
||||
/// <summary>Gets the transport stack used to reach the PLC.</summary>
|
||||
[CommandOption("transport", Description =
|
||||
"Transport stack: default, native or libplctag. default = libplctag for every --plc-type; native is " +
|
||||
"opt-in (wire-proven only for N-file reads/writes and an F8 read; PLC-5 native is N-file reads only, " +
|
||||
"LogixPccc unverified).")]
|
||||
public AbLegacyCliTransport Transport { get; init; } = AbLegacyCliTransport.Default;
|
||||
|
||||
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
|
||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||
public int TimeoutMs { get; init; } = 5000;
|
||||
@@ -56,6 +63,7 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
|
||||
RawTags = [.. tags.Select(t => new RawTagEntry(
|
||||
t.Name, AbLegacyTagDefinitionFactory.ToTagConfig(t), t.WriteIdempotent, DeviceName: t.DeviceHostAddress))],
|
||||
Timeout = Timeout,
|
||||
Transport = Transport.ToConfigured(),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ return await new CliApplicationBuilder()
|
||||
.SetExecutableName("otopcua-ablegacy-cli")
|
||||
.SetDescription(
|
||||
"OtOpcUa AB Legacy test-client — ad-hoc probe + PCCC N/F/B/L-file reads/writes + " +
|
||||
"polled subscriptions against SLC 500 / MicroLogix / PLC-5 devices via libplctag. " +
|
||||
"polled subscriptions against SLC 500 / MicroLogix / PLC-5 devices via libplctag or the native transport. " +
|
||||
"Addresses use PCCC convention: N7:0, F8:0, B3:0/3, L19:0, ST17:0.")
|
||||
.Build()
|
||||
.RunAsync(args);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
/// <summary>Transport-neutral status shared by the libplctag and native AB transports.
|
||||
/// Zero is success; every error is negative so existing <c>status != 0</c> checks stay correct.</summary>
|
||||
public enum AbStatus
|
||||
{
|
||||
Ok = 0,
|
||||
Pending = 1,
|
||||
Timeout = -1,
|
||||
NotFound = -2,
|
||||
NotAllowed = -3,
|
||||
OutOfBounds = -4,
|
||||
TooLarge = -5,
|
||||
TooSmall = -6,
|
||||
Unsupported = -7,
|
||||
BadConnection = -8,
|
||||
BadReply = -9,
|
||||
Partial = -10,
|
||||
Abort = -11,
|
||||
TypeMismatch = -12,
|
||||
DeviceStateConflict = -13,
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
public static class AbStatusCodes
|
||||
{
|
||||
public static AbStatus FromCip(byte general, ReadOnlySpan<ushort> extended) => general switch
|
||||
{
|
||||
0x00 or 0x0B => AbStatus.Ok,
|
||||
0x04 or 0x05 or 0x16 => AbStatus.NotFound,
|
||||
0x06 => AbStatus.Partial,
|
||||
0x08 => AbStatus.Unsupported,
|
||||
0x0A => AbStatus.OutOfBounds,
|
||||
0x0C or 0x10 => AbStatus.DeviceStateConflict,
|
||||
0x0E or 0x0F => AbStatus.NotAllowed,
|
||||
0x13 => AbStatus.TooSmall,
|
||||
0x15 => AbStatus.TooLarge,
|
||||
0xFF when extended.Length > 0 && extended[0] == 0x2105 => AbStatus.OutOfBounds,
|
||||
0xFF when extended.Length > 0 && extended[0] == 0x2107 => AbStatus.TypeMismatch,
|
||||
_ => AbStatus.BadReply,
|
||||
};
|
||||
|
||||
public static AbStatus FromPccc(byte sts, byte? extSts) => sts switch
|
||||
{
|
||||
0x00 => AbStatus.Ok,
|
||||
0x10 => AbStatus.Unsupported,
|
||||
0x20 => AbStatus.NotFound,
|
||||
0x30 => AbStatus.NotAllowed,
|
||||
0x40 or 0x50 => AbStatus.DeviceStateConflict,
|
||||
0xF0 when extSts is 0x06 or 0x07 => AbStatus.NotFound,
|
||||
_ => AbStatus.BadReply,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
/// <summary>Thrown by both AB transports. <see cref="Status"/> drives OPC UA status mapping and the
|
||||
/// driver's "controller answered, don't throttle the device" classification (05/STAB-8).</summary>
|
||||
public sealed class AbTransportException(AbStatus status, string message, Exception? inner = null)
|
||||
: Exception(message, inner)
|
||||
{
|
||||
public AbStatus Status { get; } = status;
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
using System.Diagnostics;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
|
||||
/// <summary>
|
||||
/// One device's CIP channel: one EtherNet/IP session and, when <see cref="CipRouting.Connected"/>, one
|
||||
/// class-3 connection. Opens lazily on first use, reconnects (through the <see cref="ForwardOpenThrottle"/>)
|
||||
/// after the session faults, and keeps an idle connection alive.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Reconnect.</b> Only a faulted session (timeout, I/O error, malformed frame) is reopened, and
|
||||
/// only by the <i>next</i> send; the failing call throws its own exception. Caller cancellation never
|
||||
/// faults the session (B2), so it never causes a reconnect.</para>
|
||||
/// <para><b>Bounded.</b> Every <see cref="SendAsync"/> is bounded even if the caller passes no token
|
||||
/// (ruling R9-3): opening (including the throttle wait and any wait behind another opener) is bounded by
|
||||
/// <c>MinForwardOpenInterval + 4 × Timeout</c> (connect, RegisterSession, Forward Open, fallback Forward
|
||||
/// Open), and the exchange by <c>2 × Timeout</c> (waiting behind one in-flight exchange, then its own).
|
||||
/// Exceeding either surfaces as <see cref="AbTransportException"/>(<see cref="AbStatus.Timeout"/>).</para>
|
||||
/// <para><b>Connection identity.</b> Every Forward Open attempt uses a fresh connection serial and T→O id,
|
||||
/// so a reopen is never mistaken by the target for a duplicate of a connection it has not yet expired.</para>
|
||||
/// </remarks>
|
||||
public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Forward Open frames sent by every connection in the process (fallback included). Gate diagnostics.</summary>
|
||||
internal static int ForwardOpenCount;
|
||||
|
||||
/// <summary>Forward Close requests put on the wire by every connection in the process. Gate diagnostics.</summary>
|
||||
internal static int ForwardClosesSent;
|
||||
|
||||
/// <summary>Forward Close requests that got a reply within their budget. Gate diagnostics.</summary>
|
||||
internal static int ForwardClosesAcked;
|
||||
|
||||
/// <summary>Channel sends by every connection in the process (keep-alives included). Gate diagnostics.</summary>
|
||||
internal static int RequestsSent;
|
||||
|
||||
// Round-trip statistics of completed exchanges, process-wide (gate diagnostics: T21 latency attribution).
|
||||
// Two Stopwatch reads and four interlocked ops per request; reset by the harness between timed passes.
|
||||
private static long _exchangeCount;
|
||||
private static long _exchangeTotalTicks;
|
||||
private static long _exchangeMinTicks = long.MaxValue;
|
||||
private static long _exchangeMaxTicks;
|
||||
|
||||
/// <summary>Round-trip statistics of the exchanges completed since the last <see cref="ResetExchangeStats"/>.</summary>
|
||||
internal readonly record struct ExchangeStats(long Count, double MinMs, double MaxMs, double MeanMs);
|
||||
|
||||
/// <summary>Clears the process-wide round-trip statistics. Gate diagnostics.</summary>
|
||||
internal static void ResetExchangeStats()
|
||||
{
|
||||
Interlocked.Exchange(ref _exchangeCount, 0);
|
||||
Interlocked.Exchange(ref _exchangeTotalTicks, 0);
|
||||
Interlocked.Exchange(ref _exchangeMinTicks, long.MaxValue);
|
||||
Interlocked.Exchange(ref _exchangeMaxTicks, 0);
|
||||
}
|
||||
|
||||
/// <summary>Snapshot of the process-wide round-trip statistics. Gate diagnostics.</summary>
|
||||
internal static ExchangeStats ReadExchangeStats()
|
||||
{
|
||||
var count = Interlocked.Read(ref _exchangeCount);
|
||||
if (count == 0) return new ExchangeStats(0, 0, 0, 0);
|
||||
static double Ms(long ticks) => ticks * 1000.0 / Stopwatch.Frequency;
|
||||
return new ExchangeStats(count, Ms(Interlocked.Read(ref _exchangeMinTicks)), Ms(Interlocked.Read(ref _exchangeMaxTicks)),
|
||||
Ms(Interlocked.Read(ref _exchangeTotalTicks)) / count);
|
||||
}
|
||||
|
||||
private static void RecordExchange(long ticks)
|
||||
{
|
||||
Interlocked.Increment(ref _exchangeCount);
|
||||
Interlocked.Add(ref _exchangeTotalTicks, ticks);
|
||||
long seen;
|
||||
while (ticks < (seen = Interlocked.Read(ref _exchangeMinTicks))
|
||||
&& Interlocked.CompareExchange(ref _exchangeMinTicks, ticks, seen) != seen) { }
|
||||
while (ticks > (seen = Interlocked.Read(ref _exchangeMaxTicks))
|
||||
&& Interlocked.CompareExchange(ref _exchangeMaxTicks, ticks, seen) != seen) { }
|
||||
}
|
||||
|
||||
/// <summary>Open attempts (TCP connect + RegisterSession + Forward Open, a fallback open included) started by
|
||||
/// every connection in the process — counted before any I/O, so a refused attempt counts too. Gate diagnostics.</summary>
|
||||
internal static int OpenAttempts;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide cap on <see cref="OpenAttempts"/>; <c>-1</c> (the default) is unlimited. An attempt beyond
|
||||
/// the cap fails with <see cref="AbStatus.Abort"/> before any I/O — no TCP connect, no Forward Open. Only the
|
||||
/// read-only differential harness (Task 18/21) sets it, so a production press that refuses or drops the
|
||||
/// connection sees exactly one open attempt instead of one per remaining operation.
|
||||
/// </summary>
|
||||
internal static int OpenAttemptBudget = -1;
|
||||
|
||||
/// <summary>Get Attribute Single, Identity (class 1) instance 1 attribute 1 (Vendor ID).</summary>
|
||||
internal static readonly byte[] KeepAliveRequest = [0x0E, 0x03, 0x20, 0x01, 0x24, 0x01, 0x30, 0x01];
|
||||
|
||||
/// <summary>Largest unconnected message a target is guaranteed to accept.</summary>
|
||||
private const int UnconnectedMessageLimit = 504;
|
||||
|
||||
private sealed record Open(EncapsulationClient Client, ForwardOpenReply? Conn, ushort ConnectionSerial, int NegotiatedSize);
|
||||
|
||||
private readonly DeviceKey _key;
|
||||
private readonly ConnectionOptions _o;
|
||||
private readonly ForwardOpenThrottle _throttle;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly Task _predecessorClosed;
|
||||
private readonly byte[] _route;
|
||||
|
||||
private readonly object _sync = new();
|
||||
// Cancels only the waits BEFORE an attempt (predecessor close, throttle interval), never an attempt
|
||||
// already on the wire. Never disposed: it has no timer, and late registrations must stay legal.
|
||||
private readonly CancellationTokenSource _disposing = new();
|
||||
private readonly ITimer? _keepAlive;
|
||||
private Task<Open>? _pendingOpen; // the one shared open in progress; under _sync
|
||||
private Open? _open; // written under _sync (Volatile); read lock-free via Volatile
|
||||
private int _disposed; // set under _sync; read lock-free via Volatile
|
||||
private int _disposeStarted;
|
||||
private long _lastSendTimestamp;
|
||||
private int _keepAlivesSent;
|
||||
private int _connectionSerial = Random.Shared.Next(1, ushort.MaxValue);
|
||||
|
||||
/// <param name="key">The device; <see cref="DeviceKey.CipPath"/> is the route (ignored for Direct).</param>
|
||||
/// <param name="options">Routing, sizes, timeouts and keep-alive settings.</param>
|
||||
/// <param name="throttle">The device's process-lifetime Forward Open throttle.</param>
|
||||
/// <param name="time">Clock for the throttle, deadlines and keep-alive.</param>
|
||||
/// <param name="predecessorClosed">Completes when the previous connection for the same key has
|
||||
/// finished closing, so two connections to one device never overlap.</param>
|
||||
internal CipConnection(DeviceKey key, ConnectionOptions options, ForwardOpenThrottle throttle, TimeProvider time,
|
||||
Task? predecessorClosed = null)
|
||||
{
|
||||
_key = key ?? throw new ArgumentNullException(nameof(key));
|
||||
_o = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_throttle = throttle ?? throw new ArgumentNullException(nameof(throttle));
|
||||
_time = time ?? throw new ArgumentNullException(nameof(time));
|
||||
_predecessorClosed = predecessorClosed ?? Task.CompletedTask;
|
||||
// Direct ignores the path entirely (B3), so it is not even parsed.
|
||||
_route = options.Routing == CipRouting.Direct ? [] : CipPath.EncodeRoute(key.CipPath);
|
||||
_lastSendTimestamp = time.GetTimestamp();
|
||||
if (options.Routing == CipRouting.Connected)
|
||||
_keepAlive = time.CreateTimer(static s => ((CipConnection)s!).OnKeepAliveTimer(), this,
|
||||
options.KeepAliveInterval, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
/// <summary>The options this connection was built with.</summary>
|
||||
internal ConnectionOptions Options => _o;
|
||||
|
||||
/// <summary>Test hook: keep-alives started (counted synchronously when the timer decides to send).</summary>
|
||||
internal int KeepAlivesSent => Volatile.Read(ref _keepAlivesSent);
|
||||
|
||||
private TimeSpan OpenBudget => _o.MinForwardOpenInterval + 4 * _o.Timeout;
|
||||
private TimeSpan ExchangeBudget => 2 * _o.Timeout;
|
||||
|
||||
public int MaxRequestBytes => _o.Routing switch
|
||||
{
|
||||
CipRouting.Connected => (Volatile.Read(ref _open)?.NegotiatedSize ?? Math.Min(_o.ConnectionSize, UnconnectedMessageLimit)) - 2,
|
||||
CipRouting.UnconnectedSend => UnconnectedMessageLimit - (12 + _route.Length),
|
||||
_ => UnconnectedMessageLimit,
|
||||
};
|
||||
|
||||
public async Task<ReadOnlyMemory<byte>> SendAsync(ReadOnlyMemory<byte> cipRequest, CancellationToken cancellationToken)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
var open = await EnsureOpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await ExchangeAsync(open, cipRequest, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Returns the live (client, connection) pair, opening one if needed. The pair is returned
|
||||
/// rather than re-read from fields afterwards, so a concurrent dispose can never hand the caller a
|
||||
/// null (finding 16).</summary>
|
||||
/// <remarks>All callers share ONE open, which runs under its own deadline only. A caller's token (or the
|
||||
/// scheduler's stop token) ends that caller's <i>wait</i>, never the open itself: cancelling a Forward
|
||||
/// Open mid-flight could leave a connection the target accepted but nobody ever closes. A completed
|
||||
/// open is stored even if disposal began meanwhile, so <see cref="DisposeAsync"/> Forward Closes it.</remarks>
|
||||
private Task<Open> EnsureOpenAsync(CancellationToken ct)
|
||||
{
|
||||
if (Volatile.Read(ref _open) is { Client.IsConnected: true } live) return Task.FromResult(live);
|
||||
|
||||
Task<Open> pending;
|
||||
TaskCompletionSource<Open>? started = null;
|
||||
lock (_sync)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (_open is { Client.IsConnected: true } current) return Task.FromResult(current);
|
||||
if (_pendingOpen is null)
|
||||
{
|
||||
started = new TaskCompletionSource<Open>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pendingOpen = started.Task;
|
||||
}
|
||||
pending = _pendingOpen;
|
||||
}
|
||||
if (started is not null)
|
||||
{
|
||||
// Every waiter may have abandoned the open (its own token); observe a failure so it never
|
||||
// surfaces as UnobservedTaskException.
|
||||
_ = started.Task.ContinueWith(static t => _ = t.Exception, CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
|
||||
_ = RunOpenAsync(started);
|
||||
}
|
||||
return pending.WaitAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>The shared open. Bounded by <see cref="OpenBudget"/> (ruling R9-3); its outcome, success or
|
||||
/// failure, goes to every caller waiting on it.</summary>
|
||||
private async Task RunOpenAsync(TaskCompletionSource<Open> result)
|
||||
{
|
||||
using var deadline = new CancellationTokenSource(OpenBudget, _time);
|
||||
using var beforeAttempt = CancellationTokenSource.CreateLinkedTokenSource(deadline.Token, _disposing.Token);
|
||||
Open? opened = null;
|
||||
Exception? error = null;
|
||||
try
|
||||
{
|
||||
Open? stale;
|
||||
lock (_sync)
|
||||
{
|
||||
stale = _open;
|
||||
Volatile.Write(ref _open, null);
|
||||
}
|
||||
if (stale is not null) await stale.Client.DisposeAsync().ConfigureAwait(false); // faulted: no UnRegister
|
||||
await _predecessorClosed.WaitAsync(beforeAttempt.Token).ConfigureAwait(false);
|
||||
opened = await _throttle.RunAsync(_ => OpenAsync(deadline.Token), beforeAttempt.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = Volatile.Read(ref _disposed) != 0
|
||||
? new AbTransportException(AbStatus.Abort, "Connection disposed.", ex)
|
||||
: ex is OperationCanceledException && deadline.IsCancellationRequested
|
||||
? new AbTransportException(AbStatus.Timeout,
|
||||
$"Opening {_key.Host}:{_key.Port} did not complete within {OpenBudget.TotalMilliseconds:F0} ms.", ex)
|
||||
: ex;
|
||||
}
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (opened is not null) Volatile.Write(ref _open, opened);
|
||||
_pendingOpen = null;
|
||||
}
|
||||
if (opened is not null) result.SetResult(opened);
|
||||
else result.SetException(error!);
|
||||
}
|
||||
|
||||
private async Task<Open> OpenAsync(CancellationToken ct)
|
||||
{
|
||||
ThrowIfDisposed(); // disposal may have begun while this attempt waited out the throttle
|
||||
var attempt = Interlocked.Increment(ref OpenAttempts);
|
||||
var budget = Volatile.Read(ref OpenAttemptBudget);
|
||||
if (budget >= 0 && attempt > budget)
|
||||
throw new AbTransportException(AbStatus.Abort,
|
||||
$"Open attempt {attempt} to {_key.Host}:{_key.Port} refused before any I/O: the process-wide open-attempt budget of {budget} is spent.");
|
||||
var client = await EncapsulationClient.ConnectAsync(_key.Host, _key.Port, _o.Timeout, ct).ConfigureAwait(false);
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
{
|
||||
// Disposal began during the TCP connect / RegisterSession: never put a Forward Open on the wire.
|
||||
await client.DisposeAsync().ConfigureAwait(false);
|
||||
throw new AbTransportException(AbStatus.Abort, "Connection disposed.");
|
||||
}
|
||||
if (_o.Routing != CipRouting.Connected) return new Open(client, null, 0, UnconnectedMessageLimit);
|
||||
try
|
||||
{
|
||||
var serial = NextConnectionSerial();
|
||||
var toId = (uint)Random.Shared.NextInt64(1, uint.MaxValue);
|
||||
var large = _o.ConnectionSize > 511;
|
||||
var size = _o.ConnectionSize;
|
||||
var reply = await ForwardOpenAsync(client, large, size, serial, toId, ct).ConfigureAwait(false);
|
||||
if (large && ConnectionManagerServices.ShouldFallBackToRegularOpen(reply))
|
||||
{
|
||||
// Pre-v20 Logix / small-buffer targets: retry once, inside this same throttled attempt.
|
||||
size = Math.Min(_o.ConnectionSize, UnconnectedMessageLimit);
|
||||
reply = await ForwardOpenAsync(client, false, size, serial, toId, ct).ConfigureAwait(false);
|
||||
}
|
||||
return new Open(client, ConnectionManagerServices.ParseForwardOpenReply(reply), serial, size);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await client.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CipReply> ForwardOpenAsync(EncapsulationClient client, bool large, int size, ushort serial,
|
||||
uint toId, CancellationToken ct)
|
||||
{
|
||||
var p = new ForwardOpenParams(large, toId, serial, _o.VendorId, _o.OriginatorSerial, _o.TimeoutMultiplier,
|
||||
_o.RpiMicros, size, _route);
|
||||
var frame = ConnectionManagerServices.ForwardOpen(p);
|
||||
Interlocked.Increment(ref ForwardOpenCount);
|
||||
return CipReply.Parse(await client.SendRRDataAsync(frame, _o.Timeout, ct).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
private ushort NextConnectionSerial()
|
||||
{
|
||||
var s = (ushort)Interlocked.Increment(ref _connectionSerial);
|
||||
return s == 0 ? (ushort)Interlocked.Increment(ref _connectionSerial) : s;
|
||||
}
|
||||
|
||||
/// <summary>One request over an established pair, bounded by <see cref="ExchangeBudget"/>.</summary>
|
||||
private async Task<ReadOnlyMemory<byte>> ExchangeAsync(Open open, ReadOnlyMemory<byte> cip, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref RequestsSent);
|
||||
Interlocked.Exchange(ref _lastSendTimestamp, _time.GetTimestamp());
|
||||
using var deadline = new CancellationTokenSource(ExchangeBudget, _time);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, deadline.Token);
|
||||
var started = Stopwatch.GetTimestamp();
|
||||
try
|
||||
{
|
||||
var reply = _o.Routing switch
|
||||
{
|
||||
CipRouting.Connected => await open.Client.SendUnitDataAsync(open.Conn!.OTConnectionId, cip, _o.Timeout, linked.Token).ConfigureAwait(false),
|
||||
CipRouting.UnconnectedSend => await open.Client.SendRRDataAsync(ConnectionManagerServices.UnconnectedSend(cip.Span, _route), _o.Timeout, linked.Token).ConfigureAwait(false),
|
||||
_ => await open.Client.SendRRDataAsync(cip, _o.Timeout, linked.Token).ConfigureAwait(false),
|
||||
};
|
||||
RecordExchange(Stopwatch.GetTimestamp() - started);
|
||||
return reply;
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested && deadline.IsCancellationRequested)
|
||||
{
|
||||
throw new AbTransportException(AbStatus.Timeout,
|
||||
$"Request to {_key.Host}:{_key.Port} did not complete within {ExchangeBudget.TotalMilliseconds:F0} ms.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One-shot timer, re-armed after every tick so the longest idle gap is one
|
||||
/// <see cref="ConnectionOptions.KeepAliveInterval"/> (half the connection expiry), not two.</summary>
|
||||
private void OnKeepAliveTimer()
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0) return;
|
||||
var interval = _o.KeepAliveInterval;
|
||||
var open = Volatile.Read(ref _open);
|
||||
if (open is not { Client.IsConnected: true })
|
||||
{
|
||||
// Nothing to keep alive, and a keep-alive must never be what reopens a connection.
|
||||
Rearm(interval);
|
||||
return;
|
||||
}
|
||||
var idle = _time.GetElapsedTime(Interlocked.Read(ref _lastSendTimestamp));
|
||||
if (idle < interval)
|
||||
{
|
||||
Rearm(interval - idle);
|
||||
return;
|
||||
}
|
||||
Interlocked.Increment(ref _keepAlivesSent);
|
||||
_ = KeepAliveAsync(open);
|
||||
}
|
||||
|
||||
private async Task KeepAliveAsync(Open open)
|
||||
{
|
||||
try { await ExchangeAsync(open, KeepAliveRequest, CancellationToken.None).ConfigureAwait(false); }
|
||||
catch (Exception)
|
||||
{
|
||||
// A timeout or I/O failure has already faulted the session; the next real send reconnects
|
||||
// through the throttle. Nothing to report here: the keep-alive has no caller.
|
||||
}
|
||||
finally { Rearm(_o.KeepAliveInterval); }
|
||||
}
|
||||
|
||||
private void Rearm(TimeSpan due)
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0) return;
|
||||
try { _keepAlive?.Change(due, Timeout.InfiniteTimeSpan); }
|
||||
catch (ObjectDisposedException) { /* disposed concurrently */ }
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
throw new AbTransportException(AbStatus.Abort, "Connection disposed.");
|
||||
}
|
||||
|
||||
/// <summary>Stops the keep-alive, sends Forward Close (Connected only) and closes the session, whose
|
||||
/// own dispose sends UnRegisterSession. Later sends throw <see cref="AbStatus.Abort"/>.</summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) return;
|
||||
|
||||
// Under the lock, so no caller can start a new open after this point. Then cancel the shared
|
||||
// open's pre-attempt waits; an attempt already on the wire is allowed to finish (bounded by
|
||||
// OpenBudget) so that whatever it opened is Forward Closed below.
|
||||
Task<Open>? pending;
|
||||
lock (_sync)
|
||||
{
|
||||
Volatile.Write(ref _disposed, 1);
|
||||
pending = _pendingOpen;
|
||||
}
|
||||
await _disposing.CancelAsync().ConfigureAwait(false);
|
||||
if (pending is not null)
|
||||
{
|
||||
try { await pending.ConfigureAwait(false); }
|
||||
catch (Exception) { /* its callers have the error; nothing was opened */ }
|
||||
}
|
||||
|
||||
Open? open;
|
||||
lock (_sync)
|
||||
{
|
||||
open = _open;
|
||||
Volatile.Write(ref _open, null);
|
||||
}
|
||||
|
||||
if (_keepAlive is not null) await _keepAlive.DisposeAsync().ConfigureAwait(false);
|
||||
if (open is null) return;
|
||||
if (open.Conn is not null && open.Client.IsConnected)
|
||||
{
|
||||
try
|
||||
{
|
||||
var budget = TimeSpan.FromSeconds(1);
|
||||
using var cts = new CancellationTokenSource(budget, _time);
|
||||
Interlocked.Increment(ref ForwardClosesSent);
|
||||
await open.Client.SendRRDataAsync(
|
||||
ConnectionManagerServices.ForwardClose(open.ConnectionSerial, _o.VendorId, _o.OriginatorSerial, _route),
|
||||
budget, cts.Token).ConfigureAwait(false);
|
||||
Interlocked.Increment(ref ForwardClosesAcked);
|
||||
}
|
||||
catch (Exception ex) when (ex is AbTransportException or OperationCanceledException)
|
||||
{
|
||||
// The target expires the connection after RPI × multiplier anyway.
|
||||
}
|
||||
}
|
||||
await open.Client.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
|
||||
public static class CipPath
|
||||
{
|
||||
/// <summary>Route path from "port,link[,port,link…]". Links that are not a byte value are IP
|
||||
/// addresses, encoded as extended link-address port segments.</summary>
|
||||
public static byte[] EncodeRoute(string cipPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cipPath)) return [];
|
||||
var w = new CipWriter();
|
||||
var parts = cipPath.Split(',', StringSplitOptions.TrimEntries);
|
||||
if (parts.Length % 2 != 0) throw new ArgumentException($"CIP path '{cipPath}' must be port,link pairs.", nameof(cipPath));
|
||||
for (var i = 0; i < parts.Length; i += 2)
|
||||
{
|
||||
var port = int.Parse(parts[i], CultureInfo.InvariantCulture);
|
||||
var link = parts[i + 1];
|
||||
// A link is a slot byte or an IP address/hostname; neither contains whitespace. Without this a
|
||||
// typo like "1,2 3" would be sent as an ASCII link "2 3" instead of failing here.
|
||||
if (link.Length == 0 || link.Any(char.IsWhiteSpace))
|
||||
throw new ArgumentException($"CIP path '{cipPath}' has an empty or malformed link '{link}'.", nameof(cipPath));
|
||||
var isByte = byte.TryParse(link, NumberStyles.None, CultureInfo.InvariantCulture, out var linkByte);
|
||||
if (port < 15)
|
||||
{
|
||||
if (isByte) w.U8((byte)port).U8(linkByte);
|
||||
else { var a = Encoding.ASCII.GetBytes(link); w.U8((byte)(0x10 | port)).U8((byte)a.Length).Bytes(a).PadToEven(); }
|
||||
}
|
||||
else
|
||||
{
|
||||
if (isByte) w.U8(0x0F).U16((ushort)port).U8(linkByte);
|
||||
else { var a = Encoding.ASCII.GetBytes(link); w.U8(0x1F).U8((byte)a.Length).U16((ushort)port).Bytes(a).PadToEven(); }
|
||||
}
|
||||
}
|
||||
return w.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Splits a trailing purely numeric member ("Tag.5") into the tag name and bit index.</summary>
|
||||
public static (string Name, int? Bit) SplitBitSelector(string tagName)
|
||||
{
|
||||
var dot = tagName.LastIndexOf('.');
|
||||
if (dot > 0 && dot < tagName.Length - 1 && tagName[(dot + 1)..].All(char.IsAsciiDigit))
|
||||
return (tagName[..dot], int.Parse(tagName[(dot + 1)..], CultureInfo.InvariantCulture));
|
||||
return (tagName, null);
|
||||
}
|
||||
|
||||
public static byte[] EncodeSymbolic(string tagName)
|
||||
{
|
||||
if (SplitBitSelector(tagName).Bit is not null)
|
||||
throw new ArgumentException($"'{tagName}' ends in a bit selector; strip it with SplitBitSelector first.", nameof(tagName));
|
||||
var w = new CipWriter();
|
||||
foreach (var part in SplitMembers(tagName))
|
||||
{
|
||||
var bracket = part.IndexOf('[');
|
||||
var name = bracket < 0 ? part : part[..bracket];
|
||||
var a = Encoding.ASCII.GetBytes(name);
|
||||
w.U8(0x91).U8((byte)a.Length).Bytes(a).PadToEven();
|
||||
if (bracket >= 0)
|
||||
{
|
||||
foreach (var idxText in part[(bracket + 1)..part.IndexOf(']')].Split(',', StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var idx = uint.Parse(idxText, CultureInfo.InvariantCulture);
|
||||
if (idx < 0x100) w.U8(0x28).U8((byte)idx);
|
||||
else if (idx < 0x10000) w.U8(0x29).U8(0).U16((ushort)idx);
|
||||
else w.U8(0x2A).U8(0).U32(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
return w.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Split on '.' outside brackets, keeping "Program:X" whole.</summary>
|
||||
private static IEnumerable<string> SplitMembers(string name)
|
||||
{
|
||||
var depth = 0; var start = 0;
|
||||
for (var i = 0; i < name.Length; i++)
|
||||
{
|
||||
if (name[i] == '[') depth++;
|
||||
else if (name[i] == ']') depth--;
|
||||
else if (name[i] == '.' && depth == 0) { yield return name[start..i]; start = i + 1; }
|
||||
}
|
||||
yield return name[start..];
|
||||
}
|
||||
|
||||
public static byte[] EncodeClassInstance(ushort cls, uint instance)
|
||||
{
|
||||
var w = new CipWriter();
|
||||
if (cls < 0x100) w.U8(0x20).U8((byte)cls); else w.U8(0x21).U8(0).U16(cls);
|
||||
if (instance < 0x100) w.U8(0x24).U8((byte)instance);
|
||||
else if (instance < 0x10000) w.U8(0x25).U8(0).U16((ushort)instance);
|
||||
else w.U8(0x26).U8(0).U32(instance);
|
||||
return w.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Prefixes the path with its size in 16-bit words.</summary>
|
||||
public static byte[] WithPathSize(byte[] segments)
|
||||
{
|
||||
var w = new CipWriter();
|
||||
w.U8((byte)(segments.Length / 2)).Bytes(segments);
|
||||
return w.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Message Router (class 2, instance 1).</summary>
|
||||
public static readonly byte[] MessageRouter = [0x20, 0x02, 0x24, 0x01];
|
||||
|
||||
/// <summary>Connection Manager (class 6, instance 1).</summary>
|
||||
public static readonly byte[] ConnectionManager = [0x20, 0x06, 0x24, 0x01];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
|
||||
/// <summary>A parsed CIP message-router reply: service (request code, 0x80 bit stripped), general
|
||||
/// status, extended status words, and the reply data.</summary>
|
||||
public sealed record CipReply(byte Service, byte GeneralStatus, ushort[] ExtendedStatus, ReadOnlyMemory<byte> Data)
|
||||
{
|
||||
public AbStatus Status => AbStatusCodes.FromCip(GeneralStatus, ExtendedStatus);
|
||||
|
||||
public static CipReply Parse(ReadOnlyMemory<byte> raw)
|
||||
{
|
||||
var r = new CipReader(raw.Span);
|
||||
var service = (byte)(r.U8() & 0x7F);
|
||||
r.U8(); // reserved
|
||||
var general = r.U8();
|
||||
var extWords = r.U8();
|
||||
var ext = new ushort[extWords];
|
||||
for (var i = 0; i < extWords; i++) ext[i] = r.U16();
|
||||
var data = raw[r.Position..];
|
||||
return new CipReply(service, general, ext, data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
|
||||
/// <summary>Parameters for a (Large) Forward Open. <see cref="ConnectionSize"/> is the maximum CIP
|
||||
/// connection payload in bytes (e.g. 4002 for Large, 504 for regular).</summary>
|
||||
public sealed record ForwardOpenParams(
|
||||
bool Large, uint TOConnectionId, ushort ConnectionSerial, ushort VendorId, uint OriginatorSerial,
|
||||
byte TimeoutMultiplier, uint RpiMicros, int ConnectionSize, byte[] Route);
|
||||
|
||||
public sealed record ForwardOpenReply(uint OTConnectionId, uint TOConnectionId, uint OTApiMicros, uint TOApiMicros);
|
||||
|
||||
/// <summary>Connection Manager (class 6) request builders and reply parsers.</summary>
|
||||
public static class ConnectionManagerServices
|
||||
{
|
||||
/// <summary>Priority/time-tick byte.</summary>
|
||||
public const byte PriorityTick = 0x0A;
|
||||
|
||||
/// <summary>Timeout ticks. The brief proposed 0x0E; every libplctag capture (Forward Open, Forward
|
||||
/// Close, Unconnected Send) carries 0x05, and the capture wins.</summary>
|
||||
public const byte TimeoutTicks = 0x05;
|
||||
|
||||
public static byte[] UnconnectedSend(ReadOnlySpan<byte> embedded, ReadOnlySpan<byte> route)
|
||||
{
|
||||
var w = new CipWriter().U8(0x52).U8(2).Bytes(CipPath.ConnectionManager)
|
||||
.U8(PriorityTick).U8(TimeoutTicks).U16((ushort)embedded.Length).Bytes(embedded);
|
||||
if ((embedded.Length & 1) == 1) w.U8(0);
|
||||
return w.U8((byte)(route.Length / 2)).U8(0).Bytes(route).ToArray();
|
||||
}
|
||||
|
||||
public static byte[] ForwardOpen(ForwardOpenParams p)
|
||||
{
|
||||
if (p.ConnectionSize < 1 || p.ConnectionSize > (p.Large ? 0xFFFF : 0x1FF))
|
||||
throw new ArgumentOutOfRangeException(nameof(p), p.ConnectionSize,
|
||||
p.Large ? "Large connection size must be 1..65535." : "Regular connection size must be 1..511.");
|
||||
var connPath = new CipWriter().Bytes(p.Route).Bytes(CipPath.MessageRouter).ToArray();
|
||||
var w = new CipWriter().U8(p.Large ? (byte)0x5B : (byte)0x54).U8(2).Bytes(CipPath.ConnectionManager)
|
||||
.U8(PriorityTick).U8(TimeoutTicks)
|
||||
.U32(0).U32(p.TOConnectionId).U16(p.ConnectionSerial).U16(p.VendorId).U32(p.OriginatorSerial)
|
||||
.U8(p.TimeoutMultiplier).U8(0).U8(0).U8(0);
|
||||
WriteSide(w, p);
|
||||
WriteSide(w, p);
|
||||
return w.U8(0xA3).U8((byte)(connPath.Length / 2)).Bytes(connPath).ToArray();
|
||||
}
|
||||
|
||||
private static void WriteSide(CipWriter w, ForwardOpenParams p)
|
||||
{
|
||||
w.U32(p.RpiMicros);
|
||||
if (p.Large) w.U32(0x42000000u | (uint)p.ConnectionSize);
|
||||
else w.U16((ushort)(0x4200 | (p.ConnectionSize & 0x1FF)));
|
||||
}
|
||||
|
||||
public static ForwardOpenReply ParseForwardOpenReply(CipReply reply)
|
||||
{
|
||||
if (reply.GeneralStatus != 0)
|
||||
throw new AbTransportException(reply.Status == AbStatus.BadReply ? AbStatus.BadConnection : reply.Status,
|
||||
$"Forward Open refused: general 0x{reply.GeneralStatus:X2}, extended {string.Join(",", reply.ExtendedStatus.Select(e => $"0x{e:X4}"))}.");
|
||||
var r = new CipReader(reply.Data.Span);
|
||||
var ot = r.U32(); var to = r.U32(); r.U16(); r.U16(); r.U32();
|
||||
return new ForwardOpenReply(ot, to, r.U32(), r.U32());
|
||||
}
|
||||
|
||||
/// <summary>True when a refused Large Forward Open should be retried as a regular one: general 0x08
|
||||
/// (Service Not Supported — pre-v20 Logix) or general 0x01 / extended 0x0109 (invalid connection size).</summary>
|
||||
public static bool ShouldFallBackToRegularOpen(CipReply r) =>
|
||||
r.GeneralStatus == 0x08
|
||||
|| (r.GeneralStatus == 0x01 && r.ExtendedStatus.Length > 0 && r.ExtendedStatus[0] == 0x0109);
|
||||
|
||||
public static byte[] ForwardClose(ushort connectionSerial, ushort vendorId, uint originatorSerial, ReadOnlySpan<byte> route)
|
||||
{
|
||||
var connPath = new CipWriter().Bytes(route).Bytes(CipPath.MessageRouter).ToArray();
|
||||
return new CipWriter().U8(0x4E).U8(2).Bytes(CipPath.ConnectionManager).U8(PriorityTick).U8(TimeoutTicks)
|
||||
.U16(connectionSerial).U16(vendorId).U32(originatorSerial)
|
||||
.U8((byte)(connPath.Length / 2)).U8(0).Bytes(connPath).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
|
||||
/// <summary>How CIP requests reach the target's message router (review finding B3; the captures decide
|
||||
/// each family's mode).</summary>
|
||||
public enum CipRouting
|
||||
{
|
||||
/// <summary>Forward Open a class-3 connection, then SendUnitData. Logix and Micro800 (empty route).</summary>
|
||||
Connected,
|
||||
|
||||
/// <summary>SendRRData wrapping each request in Unconnected Send (0x52) carrying the route.</summary>
|
||||
UnconnectedSend,
|
||||
|
||||
/// <summary>SendRRData carrying the request bare; any configured CIP path is ignored (PCCC families).</summary>
|
||||
Direct,
|
||||
}
|
||||
|
||||
/// <summary>Per-device connection settings. Validated on assignment so an unusable value fails at the
|
||||
/// configuration seam rather than on the wire.</summary>
|
||||
public sealed record ConnectionOptions
|
||||
{
|
||||
/// <summary>Press safety floor: no setting may let Forward Opens to one device start closer together.</summary>
|
||||
public static readonly TimeSpan MinimumForwardOpenInterval = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>Originator serial shared by every connection this process opens.</summary>
|
||||
private static readonly uint ProcessOriginatorSerial = (uint)Random.Shared.Next(1, int.MaxValue);
|
||||
|
||||
private readonly TimeSpan _timeout = TimeSpan.FromSeconds(5);
|
||||
private readonly int _connectionSize = 4002;
|
||||
private readonly TimeSpan _minForwardOpenInterval = MinimumForwardOpenInterval;
|
||||
private readonly uint _rpiMicros = 2_000_000;
|
||||
private readonly byte _timeoutMultiplier = 1;
|
||||
private readonly TimeSpan? _keepAliveInterval;
|
||||
|
||||
public CipRouting Routing { get; init; } = CipRouting.Connected;
|
||||
|
||||
/// <summary>Pack queued requests into Multiple Service Packets (the scheduler's switch).</summary>
|
||||
public bool AllowPacking { get; init; }
|
||||
|
||||
/// <summary>Bound on each single exchange (TCP connect, RegisterSession, Forward Open, request).
|
||||
/// Must be finite and positive (ruling R9-2): an infinite timeout would let an abandoned exchange
|
||||
/// hold the session's single-flight gate forever.</summary>
|
||||
public TimeSpan Timeout
|
||||
{
|
||||
get => _timeout;
|
||||
init => _timeout = value > TimeSpan.Zero && value != System.Threading.Timeout.InfiniteTimeSpan
|
||||
? value
|
||||
: throw new ArgumentOutOfRangeException(nameof(Timeout), value, "Timeout must be finite and positive.");
|
||||
}
|
||||
|
||||
/// <summary>Requested connection size in bytes. Above 511 the open is a Large Forward Open (0x5B).</summary>
|
||||
public int ConnectionSize
|
||||
{
|
||||
get => _connectionSize;
|
||||
init => _connectionSize = value is >= 1 and <= ushort.MaxValue
|
||||
? value
|
||||
: throw new ArgumentOutOfRangeException(nameof(ConnectionSize), value, "Connection size must be 1..65535.");
|
||||
}
|
||||
|
||||
/// <summary>Minimum time between Forward Open attempt starts for one device (≥ 1 s, press safety).</summary>
|
||||
public TimeSpan MinForwardOpenInterval
|
||||
{
|
||||
get => _minForwardOpenInterval;
|
||||
init => _minForwardOpenInterval = value >= MinimumForwardOpenInterval && value != System.Threading.Timeout.InfiniteTimeSpan
|
||||
? value
|
||||
: throw new ArgumentOutOfRangeException(nameof(MinForwardOpenInterval), value, "Must be finite and at least 1 s (press safety).");
|
||||
}
|
||||
|
||||
/// <summary>Requested packet interval for both directions, in microseconds.</summary>
|
||||
public uint RpiMicros
|
||||
{
|
||||
get => _rpiMicros;
|
||||
init => _rpiMicros = value > 0 ? value : throw new ArgumentOutOfRangeException(nameof(RpiMicros), value, "RPI must be positive.");
|
||||
}
|
||||
|
||||
/// <summary>Encoded connection timeout multiplier: the connection expires after RPI × (4 << value).
|
||||
/// The default 1 means ×8, so 2 s × 8 = 16 s.</summary>
|
||||
public byte TimeoutMultiplier
|
||||
{
|
||||
get => _timeoutMultiplier;
|
||||
init => _timeoutMultiplier = value <= 7 ? value : throw new ArgumentOutOfRangeException(nameof(TimeoutMultiplier), value, "Multiplier must be 0..7.");
|
||||
}
|
||||
|
||||
/// <summary>How long an idle connected session waits before the target would drop it.</summary>
|
||||
public TimeSpan ConnectionExpiry => TimeSpan.FromTicks(RpiMicros * 10L * (4L << TimeoutMultiplier));
|
||||
|
||||
/// <summary>Idle time after which a keep-alive is sent (Connected only). Defaults to half the
|
||||
/// connection expiry: RPI × 8 / 2 = 8 s.</summary>
|
||||
public TimeSpan KeepAliveInterval
|
||||
{
|
||||
get => _keepAliveInterval ?? ConnectionExpiry / 2;
|
||||
init => _keepAliveInterval = value > TimeSpan.Zero && value != System.Threading.Timeout.InfiniteTimeSpan
|
||||
? value
|
||||
: throw new ArgumentOutOfRangeException(nameof(KeepAliveInterval), value, "Keep-alive interval must be finite and positive.");
|
||||
}
|
||||
|
||||
public ushort VendorId { get; init; } = 0x1337;
|
||||
|
||||
/// <summary>Random per process; together with the vendor id and connection serial it names a connection.</summary>
|
||||
public uint OriginatorSerial { get; init; } = ProcessOriginatorSerial;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
|
||||
/// <summary>
|
||||
/// Identity of one physical CIP connection: exactly one <see cref="CipConnection"/> (and one Forward Open
|
||||
/// throttle) exists per key (press safety). <see cref="Host"/> and <see cref="PlcAttribute"/> compare
|
||||
/// case-insensitively; <see cref="Port"/> compares exactly; <see cref="CipPath"/> is canonicalised (each
|
||||
/// comma-separated part trimmed, so <c>"1,0"</c> and <c>" 1, 0 "</c> are the same device) and then
|
||||
/// compares exactly. Whitespace INSIDE a part is kept, so a typo such as <c>"1,2 3"</c> still fails loudly in
|
||||
/// <see cref="Cip.CipPath.EncodeRoute"/> instead of silently becoming slot 23.
|
||||
/// </summary>
|
||||
public sealed record DeviceKey(string Host, int Port, string CipPath, string PlcAttribute)
|
||||
{
|
||||
private readonly string _cipPath = Canonicalise(CipPath);
|
||||
|
||||
/// <summary>The route with each part trimmed. Canonicalised on construction and on <c>with</c>.</summary>
|
||||
public string CipPath
|
||||
{
|
||||
get => _cipPath;
|
||||
init => _cipPath = Canonicalise(value);
|
||||
}
|
||||
|
||||
private static string Canonicalise(string? path) =>
|
||||
string.IsNullOrWhiteSpace(path) ? "" : string.Join(',', path.Split(',').Select(p => p.Trim()));
|
||||
|
||||
public bool Equals(DeviceKey? other) =>
|
||||
other is not null
|
||||
&& string.Equals(Host, other.Host, StringComparison.OrdinalIgnoreCase)
|
||||
&& Port == other.Port
|
||||
&& string.Equals(CipPath, other.CipPath, StringComparison.Ordinal)
|
||||
&& string.Equals(PlcAttribute, other.PlcAttribute, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public override int GetHashCode() => HashCode.Combine(
|
||||
StringComparer.OrdinalIgnoreCase.GetHashCode(Host),
|
||||
Port,
|
||||
StringComparer.Ordinal.GetHashCode(CipPath),
|
||||
StringComparer.OrdinalIgnoreCase.GetHashCode(PlcAttribute));
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
|
||||
/// <summary>Press safety: at most one Forward Open attempt in flight per device and a minimum
|
||||
/// interval between attempt starts. A misconfigured client once drove ~15 Forward Opens/s at a
|
||||
/// production press (ignitionoee vite-press-oee-bind.md, 2026-09-23); this is what prevents it.</summary>
|
||||
/// <remarks>One instance per <see cref="DeviceKey"/>, kept by <see cref="DeviceSessionPool"/> for the
|
||||
/// process lifetime so a reconnect after the last lease was released is still limited. Its semaphore is
|
||||
/// therefore never disposed. The interval is measured from the previous attempt's <i>start</i>, whatever
|
||||
/// that attempt's outcome.</remarks>
|
||||
public sealed class ForwardOpenThrottle
|
||||
{
|
||||
private readonly SemaphoreSlim _inFlight = new(1, 1);
|
||||
private readonly TimeSpan _minInterval;
|
||||
private readonly TimeProvider _time;
|
||||
private long? _lastStartTimestamp; // monotonic (TimeProvider.GetTimestamp); only touched while holding _inFlight
|
||||
|
||||
/// <remarks>Internal: the only legitimate owner is <see cref="DeviceSessionPool"/>, which keeps exactly
|
||||
/// one per device. A second throttle for the same device would halve the guaranteed interval.</remarks>
|
||||
internal ForwardOpenThrottle(TimeSpan minInterval, TimeProvider time)
|
||||
{
|
||||
if (minInterval < TimeSpan.Zero || minInterval == Timeout.InfiniteTimeSpan)
|
||||
throw new ArgumentOutOfRangeException(nameof(minInterval), minInterval, "Interval must be finite and non-negative.");
|
||||
_minInterval = minInterval;
|
||||
_time = time ?? throw new ArgumentNullException(nameof(time));
|
||||
}
|
||||
|
||||
/// <summary>Run one open attempt once the throttle allows it. <paramref name="ct"/> bounds only the wait
|
||||
/// BEFORE the attempt (queued behind an in-flight attempt, or the interval): when it fires there, this
|
||||
/// throws <see cref="AbTransportException"/>(<see cref="AbStatus.BadConnection"/>, "Forward Open
|
||||
/// throttled"). <paramref name="ct"/> is also passed to <paramref name="open"/>, which may ignore it:
|
||||
/// <see cref="CipConnection"/> does, running the attempt under its own open deadline so that an accepted
|
||||
/// Forward Open is never abandoned without a Forward Close.</summary>
|
||||
public async Task<T> RunAsync<T>(Func<CancellationToken, Task<T>> open, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(open);
|
||||
try { await _inFlight.WaitAsync(ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{ throw Throttled("another attempt is in flight"); }
|
||||
|
||||
try
|
||||
{
|
||||
if (_lastStartTimestamp is { } last)
|
||||
{
|
||||
// Monotonic: a wall-clock step (NTP, DST, an operator) can never shorten the interval.
|
||||
var wait = _minInterval - _time.GetElapsedTime(last);
|
||||
if (wait > TimeSpan.Zero)
|
||||
{
|
||||
try { await Task.Delay(wait, _time, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{ throw Throttled("the minimum interval had not elapsed"); }
|
||||
}
|
||||
}
|
||||
_lastStartTimestamp = _time.GetTimestamp();
|
||||
return await open(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally { _inFlight.Release(); }
|
||||
}
|
||||
|
||||
private static AbTransportException Throttled(string why) =>
|
||||
new(AbStatus.BadConnection, $"Forward Open throttled; caller deadline reached before {why}.");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
|
||||
/// <summary>A path to a CIP message router: connected (Forward Open) or unconnected (UCMM). Task 9
|
||||
/// implements it; Task 10's scheduler and the native adapters consume it.</summary>
|
||||
public interface ICipChannel
|
||||
{
|
||||
/// <summary>Largest CIP request (service byte through end of data) one call may carry.</summary>
|
||||
int MaxRequestBytes { get; }
|
||||
|
||||
/// <summary>Send one CIP request and return the raw CIP reply bytes (reply-service byte first).
|
||||
/// Throws <see cref="AbTransportException"/> on transport failure or timeout.</summary>
|
||||
Task<ReadOnlyMemory<byte>> SendAsync(ReadOnlyMemory<byte> cipRequest, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
|
||||
/// <summary>Multiple Service Packet (service 0x0A on the Message Router).</summary>
|
||||
public static class MultipleServicePacket
|
||||
{
|
||||
/// <summary>Service + path size + Message Router path.</summary>
|
||||
private const int HeaderSize = 6;
|
||||
|
||||
public static byte[] Build(IReadOnlyList<byte[]> requests)
|
||||
{
|
||||
var w = new CipWriter().U8(0x0A).U8(2).Bytes(CipPath.MessageRouter).U16((ushort)requests.Count);
|
||||
var offset = 2 + 2 * requests.Count; // measured from the start of the count field
|
||||
foreach (var r in requests) { w.U16((ushort)offset); offset += r.Length; }
|
||||
foreach (var r in requests) w.Bytes(r);
|
||||
return w.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Exact byte size of an MSP carrying requests of the given sizes.</summary>
|
||||
public static int BuildSize(IReadOnlyList<int> requestSizes)
|
||||
{
|
||||
var total = HeaderSize + 2 + 2 * requestSizes.Count;
|
||||
foreach (var s in requestSizes) total += s;
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>Splits an MSP reply (the outer reply's data) into the embedded replies.</summary>
|
||||
public static IReadOnlyList<CipReply> Parse(CipReply reply, int count)
|
||||
{
|
||||
var data = reply.Data;
|
||||
var r = new CipReader(data.Span);
|
||||
var n = r.U16();
|
||||
if (n != count)
|
||||
throw new AbTransportException(AbStatus.BadReply, $"MSP reply carries {n} replies, expected {count}.");
|
||||
var offsets = new int[n];
|
||||
for (var i = 0; i < n; i++) offsets[i] = r.U16();
|
||||
var result = new CipReply[n];
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var end = i + 1 < n ? offsets[i + 1] : data.Length;
|
||||
if (offsets[i] < 2 + 2 * n || end < offsets[i] || end > data.Length)
|
||||
throw new AbTransportException(AbStatus.BadReply, $"MSP reply offset {offsets[i]}..{end} is out of range.");
|
||||
result[i] = CipReply.Parse(data[offsets[i]..end]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
|
||||
/// <summary>
|
||||
/// Per-connection CIP request scheduler. Keeps exactly one request in flight on its
|
||||
/// <see cref="ICipChannel"/>; requests that arrive while one is in flight queue, and when the channel
|
||||
/// frees the scheduler packs as many of them (FIFO) as fit in <see cref="ICipChannel.MaxRequestBytes"/>
|
||||
/// into one Multiple Service Packet. Each waiter receives its own embedded <see cref="CipReply"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One scheduler per device connection, never per tag (review finding B1). The channel is always
|
||||
/// called with the scheduler's own stop token, never a waiter's, so one caller's cancellation can
|
||||
/// never abort or fault a shared exchange (B2). A waiter cancelled while queued is removed and never
|
||||
/// sent; one cancelled while its batch is in flight is abandoned and the batch's other waiters still
|
||||
/// complete. There is deliberately no batching delay window: packing falls out of the single pump
|
||||
/// being busy on the channel while requests accumulate.
|
||||
/// </remarks>
|
||||
public sealed class RequestScheduler : IAsyncDisposable
|
||||
{
|
||||
private const byte MultipleServiceCode = 0x0A;
|
||||
private const byte EmbeddedServiceError = 0x1E;
|
||||
|
||||
private sealed class Pending(byte[] request, bool exclusive)
|
||||
{
|
||||
private bool _completed;
|
||||
private CancellationTokenRegistration _registration;
|
||||
private bool _hasRegistration;
|
||||
|
||||
public byte[] Request { get; } = request;
|
||||
|
||||
/// <summary>True when this request must never share a Multiple Service Packet with another.</summary>
|
||||
public bool Exclusive { get; } = exclusive;
|
||||
public TaskCompletionSource<CipReply> Tcs { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public LinkedListNode<Pending>? Node { get; set; }
|
||||
|
||||
/// <summary>Stores the waiter's registration; disposes it at once if the waiter already completed.</summary>
|
||||
public void SetRegistration(CancellationTokenRegistration registration, RequestScheduler owner)
|
||||
{
|
||||
lock (this)
|
||||
{
|
||||
if (!_completed)
|
||||
{
|
||||
_registration = registration;
|
||||
_hasRegistration = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
registration.Dispose();
|
||||
Interlocked.Decrement(ref owner._liveRegistrations);
|
||||
}
|
||||
|
||||
/// <summary>Marks the waiter complete exactly once and releases its registration (finding 10).
|
||||
/// Returns false if it was already completed.</summary>
|
||||
private bool Finish(RequestScheduler owner)
|
||||
{
|
||||
CancellationTokenRegistration registration;
|
||||
bool hasRegistration;
|
||||
lock (this)
|
||||
{
|
||||
if (_completed) return false;
|
||||
_completed = true;
|
||||
registration = _registration;
|
||||
hasRegistration = _hasRegistration;
|
||||
_registration = default;
|
||||
_hasRegistration = false;
|
||||
}
|
||||
if (hasRegistration)
|
||||
{
|
||||
// Safe from inside the registration's own callback: Dispose does not wait on the
|
||||
// callback that is currently executing on this thread.
|
||||
registration.Dispose();
|
||||
Interlocked.Decrement(ref owner._liveRegistrations);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Succeed(CipReply reply, RequestScheduler owner)
|
||||
{
|
||||
if (Finish(owner)) Tcs.TrySetResult(reply);
|
||||
}
|
||||
|
||||
public void Fail(Exception ex, RequestScheduler owner)
|
||||
{
|
||||
if (Finish(owner)) Tcs.TrySetException(ex);
|
||||
}
|
||||
|
||||
public void Cancel(CancellationToken ct, RequestScheduler owner)
|
||||
{
|
||||
if (Finish(owner)) Tcs.TrySetCanceled(ct);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly ICipChannel _channel;
|
||||
private readonly bool _allowPacking;
|
||||
private readonly LinkedList<Pending> _queue = new();
|
||||
private readonly object _lock = new();
|
||||
private readonly SemaphoreSlim _signal = new(0);
|
||||
private readonly CancellationTokenSource _stop = new();
|
||||
private readonly Task _pump;
|
||||
private bool _disposed;
|
||||
private int _liveRegistrations;
|
||||
private int _cancellationCallbacksRun;
|
||||
|
||||
public RequestScheduler(ICipChannel channel, bool allowPacking)
|
||||
{
|
||||
_channel = channel ?? throw new ArgumentNullException(nameof(channel));
|
||||
_allowPacking = allowPacking;
|
||||
_pump = Task.Run(PumpAsync);
|
||||
}
|
||||
|
||||
/// <summary>Test hook: waiter cancellation registrations that have not yet been disposed.</summary>
|
||||
internal int LiveRegistrations => Volatile.Read(ref _liveRegistrations);
|
||||
|
||||
/// <summary>Test hook: waiter cancellation callbacks that have actually run. A disposed
|
||||
/// registration never runs, so this stays flat when a long-lived token is cancelled later.</summary>
|
||||
internal int CancellationCallbacksRun => Volatile.Read(ref _cancellationCallbacksRun);
|
||||
|
||||
/// <summary>Test hook: requests still queued (not yet taken into a batch).</summary>
|
||||
internal int QueuedCount { get { lock (_lock) return _queue.Count; } }
|
||||
|
||||
/// <summary>
|
||||
/// Queue one CIP request. The returned reply carries the service's own general status; a non-zero
|
||||
/// status is not an exception here. Faults with <see cref="AbTransportException"/> when the channel
|
||||
/// or the MSP envelope fails, or the scheduler is disposed; cancels only when <paramref name="ct"/> fires.
|
||||
/// </summary>
|
||||
/// <param name="request">The CIP request.</param>
|
||||
/// <param name="ct">Cancels this waiter only.</param>
|
||||
/// <param name="exclusive">When true the request is sent alone, never packed into a Multiple Service
|
||||
/// Packet: for requests whose reply can fill the whole connection (the packer budgets request bytes only).</param>
|
||||
public Task<CipReply> SendAsync(byte[] request, CancellationToken ct, bool exclusive = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (ct.IsCancellationRequested) return Task.FromCanceled<CipReply>(ct);
|
||||
|
||||
var p = new Pending(request, exclusive);
|
||||
lock (_lock)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
p.Node = _queue.AddLast(p);
|
||||
}
|
||||
|
||||
if (ct.CanBeCanceled)
|
||||
{
|
||||
Interlocked.Increment(ref _liveRegistrations);
|
||||
var registration = ct.Register(static state =>
|
||||
{
|
||||
var (self, pending, token) = ((RequestScheduler, Pending, CancellationToken))state!;
|
||||
Interlocked.Increment(ref self._cancellationCallbacksRun);
|
||||
lock (self._lock)
|
||||
{
|
||||
if (pending.Node?.List is not null) self._queue.Remove(pending.Node);
|
||||
}
|
||||
pending.Cancel(token, self);
|
||||
}, (this, p, ct));
|
||||
p.SetRegistration(registration, this);
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_disposed) _signal.Release();
|
||||
}
|
||||
return p.Tcs.Task;
|
||||
}
|
||||
|
||||
/// <summary>Moves the next batch from the queue into <paramref name="batch"/>. Filling a caller-owned
|
||||
/// list means a throw part-way through still leaves every taken waiter reachable for faulting.</summary>
|
||||
private void TakeBatch(List<Pending> batch)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var sizes = new List<int>();
|
||||
while (_queue.First is { } n)
|
||||
{
|
||||
if (batch.Count > 0)
|
||||
{
|
||||
if (!_allowPacking || n.Value.Exclusive) break;
|
||||
sizes.Add(n.Value.Request.Length);
|
||||
if (MultipleServicePacket.BuildSize(sizes) > _channel.MaxRequestBytes) break;
|
||||
}
|
||||
else
|
||||
{
|
||||
// The first request always goes, even when it alone exceeds the limit: it is sent
|
||||
// bare, and callers that must fit (LogixTagClient) fragment before they get here.
|
||||
sizes.Add(n.Value.Request.Length);
|
||||
}
|
||||
batch.Add(n.Value);
|
||||
_queue.RemoveFirst();
|
||||
if (n.Value.Exclusive) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PumpAsync()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
try { await _signal.WaitAsync(_stop.Token).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
|
||||
// A signal only means "there may be work"; drain the queue fully before waiting again,
|
||||
// so a surplus signal (e.g. from a cancelled waiter) is just one empty pass.
|
||||
while (!_stop.IsCancellationRequested)
|
||||
{
|
||||
var batch = new List<Pending>();
|
||||
try
|
||||
{
|
||||
TakeBatch(batch);
|
||||
if (batch.Count == 0) break;
|
||||
await ExecuteAsync(batch).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Last resort: ExecuteAsync already handles every exchange failure, so reaching here
|
||||
// means the scheduler itself broke (e.g. MaxRequestBytes threw). Fault everything
|
||||
// pending rather than let the pump die and leave callers hanging, then keep pumping
|
||||
// so later requests still get an answer.
|
||||
FaultAll(batch, ex);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void FaultAll(List<Pending> batch, Exception ex)
|
||||
{
|
||||
var error = ex as AbTransportException
|
||||
?? new AbTransportException(AbStatus.BadConnection, $"Request scheduler failed: {ex.Message}", ex);
|
||||
List<Pending> queued;
|
||||
lock (_lock)
|
||||
{
|
||||
queued = [.. _queue];
|
||||
_queue.Clear();
|
||||
}
|
||||
foreach (var b in batch) b.Fail(error, this);
|
||||
foreach (var q in queued) q.Fail(error, this);
|
||||
}
|
||||
|
||||
private async Task ExecuteAsync(List<Pending> batch)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (batch.Count == 1)
|
||||
{
|
||||
// Never a waiter's token (B2).
|
||||
var raw = await _channel.SendAsync(batch[0].Request, _stop.Token).ConfigureAwait(false);
|
||||
batch[0].Succeed(CipReply.Parse(raw), this);
|
||||
return;
|
||||
}
|
||||
|
||||
var requests = new List<byte[]>(batch.Count);
|
||||
foreach (var b in batch) requests.Add(b.Request);
|
||||
var envelopeRaw = await _channel.SendAsync(MultipleServicePacket.Build(requests), _stop.Token).ConfigureAwait(false);
|
||||
var envelope = CipReply.Parse(envelopeRaw);
|
||||
if (envelope.Service != MultipleServiceCode)
|
||||
throw new AbTransportException(AbStatus.BadReply, $"MSP reply carries service 0x{envelope.Service:X2}.");
|
||||
if (envelope.GeneralStatus is not (0x00 or EmbeddedServiceError))
|
||||
throw new AbTransportException(envelope.Status, $"Multiple Service Packet failed: general 0x{envelope.GeneralStatus:X2}.");
|
||||
var replies = MultipleServicePacket.Parse(envelope, batch.Count);
|
||||
for (var i = 0; i < batch.Count; i++) batch[i].Succeed(replies[i], this);
|
||||
}
|
||||
catch (OperationCanceledException) when (_stop.IsCancellationRequested)
|
||||
{
|
||||
// The scheduler is being disposed: not the caller's cancellation, so not an OCE.
|
||||
var error = DisposedError();
|
||||
foreach (var b in batch) b.Fail(error, this);
|
||||
}
|
||||
catch (AbTransportException ex)
|
||||
{
|
||||
foreach (var b in batch) b.Fail(ex, this);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var wrapped = new AbTransportException(AbStatus.BadConnection, $"CIP exchange failed: {ex.Message}", ex);
|
||||
foreach (var b in batch) b.Fail(wrapped, this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The error a waiter sees when the scheduler (i.e. its connection) goes away under it. Deliberately
|
||||
/// NOT an <see cref="OperationCanceledException"/>: upstream treats OCE as "the caller unsubscribed"
|
||||
/// and swallows it, whereas this must surface as a communication failure. A waiter cancelled by its
|
||||
/// own token still gets an OCE.
|
||||
/// </summary>
|
||||
private static AbTransportException DisposedError() =>
|
||||
new(AbStatus.BadConnection, "Scheduler disposed.");
|
||||
|
||||
/// <summary>Stops the pump. In-flight and queued waiters fault with
|
||||
/// <see cref="AbTransportException"/> (<see cref="AbStatus.BadConnection"/>).</summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
}
|
||||
_stop.Cancel();
|
||||
try { await _pump.ConfigureAwait(false); } catch { /* stopping */ }
|
||||
|
||||
List<Pending> remaining;
|
||||
lock (_lock)
|
||||
{
|
||||
remaining = [.. _queue];
|
||||
_queue.Clear();
|
||||
}
|
||||
var error = DisposedError();
|
||||
foreach (var p in remaining) p.Fail(error, this);
|
||||
_signal.Dispose();
|
||||
_stop.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide owner of device connections: exactly one <see cref="CipConnection"/> and one
|
||||
/// <see cref="RequestScheduler"/> per <see cref="DeviceKey"/> (review B1), handed out as ref-counted
|
||||
/// <see cref="ConnectionLease"/>s. The last lease's dispose closes the connection; a later acquire builds
|
||||
/// fresh instances, which do not open until the previous connection has finished closing.
|
||||
/// </summary>
|
||||
/// <remarks>Forward Open throttles are kept per key for the process lifetime and never pruned, so a
|
||||
/// reconnect after the last release is still rate-limited against the previous attempt.</remarks>
|
||||
public sealed class DeviceSessionPool
|
||||
{
|
||||
/// <summary>The process-wide pool the drivers use. Tests build their own.</summary>
|
||||
public static DeviceSessionPool Shared { get; } = new();
|
||||
|
||||
internal sealed class Entry(CipConnection connection, RequestScheduler scheduler, ConnectionOptions options,
|
||||
Task predecessorClosed)
|
||||
{
|
||||
public CipConnection Connection { get; } = connection;
|
||||
/// <summary>Completes when every earlier connection for this key has finished closing.</summary>
|
||||
public Task PredecessorClosed { get; } = predecessorClosed;
|
||||
public RequestScheduler Scheduler { get; } = scheduler;
|
||||
public ConnectionOptions Options { get; } = options;
|
||||
public int RefCount; // under the pool lock
|
||||
public bool MismatchLogged; // under the pool lock
|
||||
}
|
||||
|
||||
private readonly object _lock = new();
|
||||
private readonly Dictionary<DeviceKey, Entry> _entries = new();
|
||||
private readonly Dictionary<DeviceKey, ForwardOpenThrottle> _throttles = new();
|
||||
private readonly Dictionary<DeviceKey, Task> _closing = new();
|
||||
private readonly TimeProvider _time;
|
||||
private volatile ILogger _logger;
|
||||
|
||||
public DeviceSessionPool(TimeProvider? time = null, ILogger? logger = null)
|
||||
{
|
||||
_time = time ?? TimeProvider.System;
|
||||
_logger = logger ?? NullLogger.Instance;
|
||||
}
|
||||
|
||||
/// <summary>Give <see cref="Shared"/> a logger (it is built before any host exists), so its
|
||||
/// options-mismatch and close-failure warnings reach the service log. Call once at startup.</summary>
|
||||
public static void ConfigureSharedLogger(ILoggerFactory loggerFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(loggerFactory);
|
||||
Shared._logger = loggerFactory.CreateLogger<DeviceSessionPool>();
|
||||
}
|
||||
|
||||
/// <summary>Wiring-test hook: the logger this pool currently writes to (final review I2).</summary>
|
||||
internal ILogger Logger => _logger;
|
||||
|
||||
/// <summary>Test hook: keys with a live entry.</summary>
|
||||
internal int EntryCount { get { lock (_lock) return _entries.Count; } }
|
||||
|
||||
/// <summary>Lease the shared connection for <paramref name="key"/>, creating it on first use. Performs
|
||||
/// no I/O: the connection opens on its first request. When the key already has an entry, that entry's
|
||||
/// options win; a differing <paramref name="options"/> is logged once per entry.</summary>
|
||||
public ConnectionLease Acquire(DeviceKey key, ConnectionOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_entries.TryGetValue(key, out var entry))
|
||||
{
|
||||
if (!_throttles.TryGetValue(key, out var throttle))
|
||||
_throttles[key] = throttle = new ForwardOpenThrottle(options.MinForwardOpenInterval, _time);
|
||||
var predecessor = _closing.GetValueOrDefault(key) ?? Task.CompletedTask;
|
||||
var connection = new CipConnection(key, options, throttle, _time, predecessor);
|
||||
entry = new Entry(connection, new RequestScheduler(connection, options.AllowPacking), options, predecessor);
|
||||
_entries[key] = entry;
|
||||
}
|
||||
else if (!entry.Options.Equals(options) && !entry.MismatchLogged)
|
||||
{
|
||||
entry.MismatchLogged = true;
|
||||
_logger.LogWarning(
|
||||
"Device {Host}:{Port} path '{CipPath}' is already connected with different connection options; " +
|
||||
"the existing options ({Existing}) stay in force and the requested ({Requested}) are ignored.",
|
||||
key.Host, key.Port, key.CipPath, entry.Options, options);
|
||||
}
|
||||
entry.RefCount++;
|
||||
return new ConnectionLease(this, key, entry);
|
||||
}
|
||||
}
|
||||
|
||||
internal async ValueTask ReleaseAsync(DeviceKey key, Entry entry)
|
||||
{
|
||||
TaskCompletionSource closed;
|
||||
Task chain;
|
||||
lock (_lock)
|
||||
{
|
||||
if (--entry.RefCount > 0) return;
|
||||
_entries.Remove(key);
|
||||
closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
// The successor must wait for THIS close and for every earlier one still running. An entry
|
||||
// that never opened closes instantly; without the chain it would hide a slow predecessor
|
||||
// (A closing, B acquired+released unopened, C opens while A's Forward Close is still pending).
|
||||
chain = Task.WhenAll(entry.PredecessorClosed, closed.Task);
|
||||
_closing[key] = chain;
|
||||
}
|
||||
_ = chain.ContinueWith(_ =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_closing.TryGetValue(key, out var t) && t == chain) _closing.Remove(key);
|
||||
}
|
||||
}, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
|
||||
|
||||
// Outside the lock: scheduler first (fails its waiters, stops sending), then Forward Close +
|
||||
// UnRegisterSession via the connection. The connection is disposed even if the scheduler's
|
||||
// dispose throws: otherwise its keep-alive would hold the press connection open forever.
|
||||
try
|
||||
{
|
||||
try { await entry.Scheduler.DisposeAsync().ConfigureAwait(false); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Disposing the request scheduler for {Host}:{Port} failed; closing the connection anyway.",
|
||||
key.Host, key.Port);
|
||||
}
|
||||
try { await entry.Connection.DisposeAsync().ConfigureAwait(false); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Closing the connection to {Host}:{Port} failed.", key.Host, key.Port);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
closed.TrySetResult(); // never faults: a successor only needs to know the close is over
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A reference on a device's shared connection. Dispose exactly once (further disposes are no-ops).</summary>
|
||||
public sealed class ConnectionLease : IAsyncDisposable
|
||||
{
|
||||
private readonly DeviceSessionPool _pool;
|
||||
private readonly DeviceKey _key;
|
||||
private readonly DeviceSessionPool.Entry _entry;
|
||||
private int _disposed;
|
||||
|
||||
internal ConnectionLease(DeviceSessionPool pool, DeviceKey key, DeviceSessionPool.Entry entry)
|
||||
{
|
||||
_pool = pool;
|
||||
_key = key;
|
||||
_entry = entry;
|
||||
}
|
||||
|
||||
public DeviceKey Key => _key;
|
||||
|
||||
/// <summary>The device's channel. Prefer <see cref="Scheduler"/>: it serializes and packs requests.</summary>
|
||||
public ICipChannel Channel => _entry.Connection;
|
||||
|
||||
/// <summary>The device's single request scheduler, shared by every lease on the key.</summary>
|
||||
public RequestScheduler Scheduler => _entry.Scheduler;
|
||||
|
||||
public ValueTask DisposeAsync() =>
|
||||
Interlocked.Exchange(ref _disposed, 1) == 0 ? _pool.ReleaseAsync(_key, _entry) : ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using System.Net.Sockets;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
|
||||
|
||||
/// <summary>
|
||||
/// One EtherNet/IP TCP session: connects, registers, and sends SendRRData (unconnected) and
|
||||
/// SendUnitData (connected) requests with <b>at most one request in flight</b>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Single flight.</b> Every exchange (write one frame, read until its reply) runs under a
|
||||
/// <see cref="SemaphoreSlim"/>(1,1). The connected sequence count is advanced inside that gate, so
|
||||
/// concurrent callers can never put sequences on the wire out of order.</para>
|
||||
/// <para><b>Caller cancellation never faults the connection, and never misattributes a reply</b>
|
||||
/// (rulings R5-1/R5-2). The caller gets <see cref="OperationCanceledException"/> at once, but the
|
||||
/// abandoned exchange keeps reading <i>its own</i> reply in the background while still holding the
|
||||
/// gate, bounded by the original timeout. The reply is discarded and the connection stays healthy;
|
||||
/// if the timeout elapses first the connection faults exactly as a normal timeout does. Because the
|
||||
/// next request is not written until the previous reply has been consumed, a late reply can never be
|
||||
/// taken for the next request's — which matters because ab_server zeroes the reply sender context,
|
||||
/// so context matching alone cannot tell two unconnected replies apart.</para>
|
||||
/// <para><b>Faults.</b> A timeout, an I/O error or an encapsulation-level protocol error
|
||||
/// (<see cref="AbStatus.BadReply"/>) closes the socket; <see cref="IsConnected"/> turns false and
|
||||
/// every later call throws <see cref="AbTransportException"/>(<see cref="AbStatus.BadConnection"/>).
|
||||
/// The owner reconnects with a fresh client.</para>
|
||||
/// <para><b>No half-written frames.</b> A frame write never observes a cancellation token.</para>
|
||||
/// </remarks>
|
||||
public sealed class EncapsulationClient : IAsyncDisposable
|
||||
{
|
||||
private static readonly TimeSpan UnregisterBudget = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
private readonly TcpClient _tcp;
|
||||
private readonly NetworkStream _stream;
|
||||
|
||||
// Never disposed: an abandoned exchange may release it after DisposeAsync returns, and
|
||||
// SemaphoreSlim.Release on a disposed instance throws. It holds no unmanaged resource unless
|
||||
// AvailableWaitHandle is touched, which this class never does.
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private long _ctx;
|
||||
private ushort _seq; // connected sequence count; only touched under _gate
|
||||
private volatile bool _faulted;
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>The session handle the target assigned in its RegisterSession reply.</summary>
|
||||
public uint SessionHandle { get; private set; }
|
||||
|
||||
/// <summary>False once the connection has faulted or been disposed.</summary>
|
||||
public bool IsConnected => !_faulted && Volatile.Read(ref _disposed) == 0 && _tcp.Connected;
|
||||
|
||||
private EncapsulationClient(TcpClient tcp)
|
||||
{
|
||||
_tcp = tcp;
|
||||
_stream = tcp.GetStream();
|
||||
}
|
||||
|
||||
/// <summary>Open the TCP connection and register an encapsulation session. <paramref name="timeout"/>
|
||||
/// bounds the TCP connect and the RegisterSession exchange separately.</summary>
|
||||
public static async Task<EncapsulationClient> ConnectAsync(string host, int port, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
var tcp = new TcpClient { NoDelay = true };
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(timeout);
|
||||
await tcp.ConnectAsync(host, port, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
tcp.Dispose();
|
||||
throw new AbTransportException(AbStatus.Timeout, $"TCP connect to {host}:{port} timed out.");
|
||||
}
|
||||
catch (SocketException ex)
|
||||
{
|
||||
tcp.Dispose();
|
||||
throw new AbTransportException(AbStatus.BadConnection, $"TCP connect to {host}:{port} failed: {ex.SocketErrorCode}", ex);
|
||||
}
|
||||
catch
|
||||
{
|
||||
tcp.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
var client = new EncapsulationClient(tcp);
|
||||
try
|
||||
{
|
||||
var (header, _) = await client.ExchangeAsync(connected: false,
|
||||
(_, _) => EncapsulationCodec.RegisterSession(), timeout, ct).ConfigureAwait(false);
|
||||
client.SessionHandle = header.SessionHandle;
|
||||
return client;
|
||||
}
|
||||
catch
|
||||
{
|
||||
client.Abort();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Send one unconnected (UCMM) CIP request and return the CIP reply bytes.</summary>
|
||||
public async Task<ReadOnlyMemory<byte>> SendRRDataAsync(ReadOnlyMemory<byte> cip, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
var secs = (ushort)Math.Clamp((int)Math.Ceiling(timeout.TotalSeconds), 1, ushort.MaxValue);
|
||||
var (_, reply) = await ExchangeAsync(connected: false,
|
||||
(ctx, _) => EncapsulationCodec.SendRRData(SessionHandle, ctx, cip.Span, secs), timeout, ct).ConfigureAwait(false);
|
||||
return reply;
|
||||
}
|
||||
|
||||
/// <summary>Send one connected (class 3) CIP request over <paramref name="otConnectionId"/> and return
|
||||
/// the CIP reply bytes. The client owns the connected sequence count (review B1).</summary>
|
||||
public async Task<ReadOnlyMemory<byte>> SendUnitDataAsync(uint otConnectionId, ReadOnlyMemory<byte> cip,
|
||||
TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
var (_, reply) = await ExchangeAsync(connected: true,
|
||||
(ctx, seq) => EncapsulationCodec.SendUnitData(SessionHandle, ctx, otConnectionId, seq, cip.Span), timeout, ct)
|
||||
.ConfigureAwait(false);
|
||||
return reply;
|
||||
}
|
||||
|
||||
/// <summary>The connected sequence count after <paramref name="current"/>: increments, wrapping
|
||||
/// 65535 → 1 (0 is never put on the wire).</summary>
|
||||
internal static ushort NextSequence(ushort current) => current == ushort.MaxValue ? (ushort)1 : (ushort)(current + 1);
|
||||
|
||||
/// <summary>Acquire the gate, build the request (sequence assigned under the gate), then hand the
|
||||
/// gate to <see cref="RunExchangeAsync"/>, which owns its release from that point on.</summary>
|
||||
private async Task<(EncapsulationHeader Header, ReadOnlyMemory<byte> Cip)> ExchangeAsync(
|
||||
bool connected, Func<ulong, ushort, byte[]> build, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
ThrowIfUnusable();
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
|
||||
Task<(EncapsulationHeader, ReadOnlyMemory<byte>)> exchange;
|
||||
try
|
||||
{
|
||||
ThrowIfUnusable(); // a drain we queued behind may have faulted
|
||||
ct.ThrowIfCancellationRequested(); // nothing written yet: cancel cleanly
|
||||
var ctx = (ulong)Interlocked.Increment(ref _ctx);
|
||||
ushort seq = 0;
|
||||
if (connected) seq = _seq = NextSequence(_seq);
|
||||
var request = build(ctx, seq);
|
||||
exchange = RunExchangeAsync(request, connected, ctx, seq, timeout);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_gate.Release(); // RunExchangeAsync never started: release here
|
||||
throw;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await exchange.WaitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Caller abandoned the wait (R5-1). The exchange keeps draining its own reply under the
|
||||
// gate and faults on its own timeout; observe its outcome so a fault is never unobserved.
|
||||
_ = exchange.ContinueWith(static t => _ = t.Exception, CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Write one frame, then read frames until the matching reply arrives. Releases the gate
|
||||
/// exactly once, in its <c>finally</c>. Never throws <see cref="OperationCanceledException"/>: every
|
||||
/// failure surfaces as <see cref="AbTransportException"/> and faults the connection first.</summary>
|
||||
private async Task<(EncapsulationHeader, ReadOnlyMemory<byte>)> RunExchangeAsync(
|
||||
byte[] request, bool connected, ulong ctx, ushort seq, TimeSpan timeout)
|
||||
{
|
||||
var cmd = (ushort)(request[0] | (request[1] << 8));
|
||||
try
|
||||
{
|
||||
using var timeoutCts = new CancellationTokenSource(timeout);
|
||||
try
|
||||
{
|
||||
await _stream.WriteAsync(request, CancellationToken.None).ConfigureAwait(false);
|
||||
while (true)
|
||||
{
|
||||
var frame = await ReadFrameAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
if (TryMatch(frame, cmd, connected, ctx, seq, out var reply)) return reply;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
|
||||
{
|
||||
Fault();
|
||||
throw new AbTransportException(AbStatus.Timeout,
|
||||
$"No reply to command 0x{cmd:X4} within {timeout.TotalMilliseconds:F0} ms.");
|
||||
}
|
||||
catch (AbTransportException)
|
||||
{
|
||||
Fault();
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or SocketException or ObjectDisposedException
|
||||
or EndOfStreamException or InvalidOperationException)
|
||||
{
|
||||
Fault();
|
||||
throw new AbTransportException(AbStatus.BadConnection, $"Connection lost: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Does <paramref name="frame"/> answer the request in flight? Unconnected replies match on
|
||||
/// command and sender context, tolerating a zeroed context (ab_server, R5-2); connected replies match
|
||||
/// on the echoed sequence count. Anything else is skipped. A matching frame is fully validated, so an
|
||||
/// encapsulation error throws BadReply.</summary>
|
||||
private static bool TryMatch(byte[] frame, ushort cmd, bool connected, ulong ctx, ushort seq,
|
||||
out (EncapsulationHeader, ReadOnlyMemory<byte>) reply)
|
||||
{
|
||||
reply = default;
|
||||
var h = EncapsulationCodec.ReadHeader(frame);
|
||||
if (h.Command != cmd) return false;
|
||||
|
||||
if (!connected)
|
||||
{
|
||||
if (h.SenderContext != ctx && h.SenderContext != 0) return false;
|
||||
var p = EncapsulationCodec.ParseReply(frame, expectConnected: false);
|
||||
reply = (p.Header, p.Cip);
|
||||
return true;
|
||||
}
|
||||
|
||||
// A connected error reply (status != 0) carries no readable sequence; with one request in flight
|
||||
// and abandoned exchanges drained, it can only be ours — ParseReply throws BadReply for it.
|
||||
var c = EncapsulationCodec.ParseReply(frame, expectConnected: true);
|
||||
if (c.Sequence != seq) return false;
|
||||
reply = (c.Header, c.Cip);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<byte[]> ReadFrameAsync(CancellationToken ct)
|
||||
{
|
||||
var head = new byte[EncapsulationHeader.Size];
|
||||
await _stream.ReadExactlyAsync(head, ct).ConfigureAwait(false);
|
||||
var len = head[2] | (head[3] << 8);
|
||||
var frame = new byte[EncapsulationHeader.Size + len];
|
||||
head.CopyTo(frame, 0);
|
||||
await _stream.ReadExactlyAsync(frame.AsMemory(EncapsulationHeader.Size), ct).ConfigureAwait(false);
|
||||
return frame;
|
||||
}
|
||||
|
||||
private void ThrowIfUnusable()
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
throw new AbTransportException(AbStatus.BadConnection, "Session is disposed.");
|
||||
if (_faulted)
|
||||
throw new AbTransportException(AbStatus.BadConnection, "Session is faulted; reconnect.");
|
||||
}
|
||||
|
||||
private void Fault()
|
||||
{
|
||||
_faulted = true;
|
||||
try { _tcp.Close(); } catch { /* already closed */ }
|
||||
}
|
||||
|
||||
/// <summary>Close without UnRegisterSession (registration never completed).</summary>
|
||||
private void Abort()
|
||||
{
|
||||
Interlocked.Exchange(ref _disposed, 1);
|
||||
Fault();
|
||||
}
|
||||
|
||||
/// <summary>Best-effort UnRegisterSession, then close. Waits briefly for an in-flight exchange so the
|
||||
/// UnRegisterSession frame is never interleaved with another frame; if the gate is not free in time
|
||||
/// the socket is closed without it.</summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
var acquired = await _gate.WaitAsync(UnregisterBudget).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (acquired && !_faulted && _tcp.Connected)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = EncapsulationCodec.UnRegisterSession(SessionHandle, (ulong)Interlocked.Increment(ref _ctx));
|
||||
using var cts = new CancellationTokenSource(UnregisterBudget);
|
||||
await _stream.WriteAsync(bytes, cts.Token).ConfigureAwait(false);
|
||||
await _stream.FlushAsync(cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch { /* best effort: the socket is closed next either way */ }
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Fault();
|
||||
_tcp.Dispose();
|
||||
if (acquired) _gate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
|
||||
|
||||
/// <summary>Builds and parses EtherNet/IP encapsulation frames, including the Common Packet Format
|
||||
/// (CPF) items SendRRData / SendUnitData carry.</summary>
|
||||
public static class EncapsulationCodec
|
||||
{
|
||||
/// <summary>CPF item: Null Address (unconnected messaging).</summary>
|
||||
public const ushort ItemNullAddress = 0x0000;
|
||||
|
||||
/// <summary>CPF item: Connected Address (carries the O→T connection id).</summary>
|
||||
public const ushort ItemConnectedAddress = 0x00A1;
|
||||
|
||||
/// <summary>CPF item: Connected Data (sequence count + CIP).</summary>
|
||||
public const ushort ItemConnectedData = 0x00B1;
|
||||
|
||||
/// <summary>CPF item: Unconnected Data (CIP).</summary>
|
||||
public const ushort ItemUnconnectedData = 0x00B2;
|
||||
|
||||
/// <summary>Largest CIP payload one frame can carry: the encapsulation length field (u16) also
|
||||
/// covers the CPF overhead, so anything near 64 KiB cannot be framed.</summary>
|
||||
public const int MaxCipPayload = ushort.MaxValue - 64;
|
||||
|
||||
private static CipWriter Header(ushort cmd, uint session, ulong ctx) =>
|
||||
new CipWriter().U16(cmd).U16(0).U32(session).U32(0).U64(ctx).U32(0);
|
||||
|
||||
private static byte[] Finish(CipWriter w)
|
||||
{
|
||||
w.PatchU16(2, (ushort)(w.Length - EncapsulationHeader.Size));
|
||||
return w.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>RegisterSession: protocol version 1, option flags 0, and a <b>zero</b> sender context.</summary>
|
||||
/// <remarks>The sender context is always zero, as libplctag sends it (<c>logix-register-session.hex</c>):
|
||||
/// ab_server's RegisterSession sanity check rejects any non-zero context with encapsulation status 1
|
||||
/// and a header-only reply, so a per-request context here failed every native session against the
|
||||
/// simulator. The reply is still matched — unconnected matching tolerates a zero context.</remarks>
|
||||
public static byte[] RegisterSession() =>
|
||||
Finish(Header(EncapCommand.RegisterSession, 0, 0).U16(1).U16(0));
|
||||
|
||||
/// <summary>UnRegisterSession: header only, no data.</summary>
|
||||
public static byte[] UnRegisterSession(uint session, ulong ctx) =>
|
||||
Finish(Header(EncapCommand.UnRegisterSession, session, ctx));
|
||||
|
||||
/// <summary>SendRRData (unconnected): Null Address item + Unconnected Data item carrying <paramref name="cip"/>.</summary>
|
||||
public static byte[] SendRRData(uint session, ulong ctx, ReadOnlySpan<byte> cip, ushort timeoutSeconds) =>
|
||||
Finish(Header(EncapCommand.SendRRData, session, ctx)
|
||||
.U32(0).U16(timeoutSeconds).U16(2)
|
||||
.U16(ItemNullAddress).U16(0)
|
||||
.U16(ItemUnconnectedData).U16(CheckedLength(cip.Length)).Bytes(cip));
|
||||
|
||||
/// <summary>SendUnitData (connected): Connected Address item + Connected Data item carrying the
|
||||
/// sequence count followed by <paramref name="cip"/>.</summary>
|
||||
public static byte[] SendUnitData(uint session, ulong ctx, uint connectionId, ushort sequence, ReadOnlySpan<byte> cip) =>
|
||||
Finish(Header(EncapCommand.SendUnitData, session, ctx)
|
||||
.U32(0).U16(0).U16(2)
|
||||
.U16(ItemConnectedAddress).U16(4).U32(connectionId)
|
||||
.U16(ItemConnectedData).U16((ushort)(CheckedLength(cip.Length) + 2)).U16(sequence).Bytes(cip));
|
||||
|
||||
private static ushort CheckedLength(int length)
|
||||
{
|
||||
if (length > MaxCipPayload)
|
||||
throw new AbTransportException(AbStatus.TooLarge, $"CIP payload of {length} bytes cannot be framed (max {MaxCipPayload}).");
|
||||
return (ushort)length;
|
||||
}
|
||||
|
||||
/// <summary>Read the 24-byte header at the start of <paramref name="b"/>.</summary>
|
||||
public static EncapsulationHeader ReadHeader(ReadOnlySpan<byte> b)
|
||||
{
|
||||
var r = new CipReader(b);
|
||||
return new EncapsulationHeader(r.U16(), r.U16(), r.U32(), r.U32(), r.U64(), r.U32());
|
||||
}
|
||||
|
||||
/// <summary>Parse a reply frame. Returns the header plus the CIP payload of the data item
|
||||
/// (0x00B2 unconnected / 0x00B1 connected, sequence count stripped) and, for a connected reply,
|
||||
/// the echoed sequence count. RegisterSession / UnRegisterSession replies return an empty payload.
|
||||
/// Throws <see cref="AbTransportException"/>(<see cref="AbStatus.BadReply"/>) on a non-zero
|
||||
/// encapsulation status, a length mismatch, a truncated item list or a missing data item.</summary>
|
||||
public static (EncapsulationHeader Header, ReadOnlyMemory<byte> Cip, ushort Sequence) ParseReply(
|
||||
ReadOnlyMemory<byte> frame, bool expectConnected)
|
||||
{
|
||||
var h = ReadHeader(frame.Span);
|
||||
if (h.Status != 0)
|
||||
throw new AbTransportException(AbStatus.BadReply, $"Encapsulation status 0x{h.Status:X8} on command 0x{h.Command:X4}.");
|
||||
if (h.Length != frame.Length - EncapsulationHeader.Size)
|
||||
throw new AbTransportException(AbStatus.BadReply,
|
||||
$"Encapsulation length mismatch: header says {h.Length}, frame carries {frame.Length - EncapsulationHeader.Size}.");
|
||||
if (h.Command is EncapCommand.RegisterSession or EncapCommand.UnRegisterSession)
|
||||
return (h, ReadOnlyMemory<byte>.Empty, 0);
|
||||
|
||||
var r = new CipReader(frame.Span[EncapsulationHeader.Size..]);
|
||||
r.U32(); r.U16(); // interface handle, timeout
|
||||
var items = r.U16();
|
||||
var offset = EncapsulationHeader.Size + 8;
|
||||
var wanted = expectConnected ? ItemConnectedData : ItemUnconnectedData;
|
||||
for (var i = 0; i < items; i++)
|
||||
{
|
||||
var type = r.U16();
|
||||
var len = r.U16();
|
||||
offset += 4;
|
||||
r.Bytes(len); // bounds-checks the item against the frame
|
||||
if (type == wanted)
|
||||
{
|
||||
if (!expectConnected) return (h, frame.Slice(offset, len), 0);
|
||||
if (len < 2)
|
||||
throw new AbTransportException(AbStatus.BadReply, "Connected data item shorter than its sequence count.");
|
||||
var seq = (ushort)(frame.Span[offset] | (frame.Span[offset + 1] << 8));
|
||||
return (h, frame.Slice(offset + 2, len - 2), seq);
|
||||
}
|
||||
offset += len;
|
||||
}
|
||||
throw new AbTransportException(AbStatus.BadReply,
|
||||
$"Reply to command 0x{h.Command:X4} carried no 0x{wanted:X4} data item.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
|
||||
|
||||
/// <summary>EtherNet/IP encapsulation command codes (CIP Vol. 2, Table 2-3.2).</summary>
|
||||
public static class EncapCommand
|
||||
{
|
||||
public const ushort ListIdentity = 0x0063, RegisterSession = 0x0065, UnRegisterSession = 0x0066,
|
||||
SendRRData = 0x006F, SendUnitData = 0x0070;
|
||||
}
|
||||
|
||||
/// <summary>The fixed 24-byte EtherNet/IP encapsulation header (all fields little-endian).</summary>
|
||||
public readonly record struct EncapsulationHeader(
|
||||
ushort Command, ushort Length, uint SessionHandle, uint Status, ulong SenderContext, uint Options)
|
||||
{
|
||||
public const int Size = 24;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
|
||||
|
||||
/// <summary>Logix Read/Write Tag (plain and fragmented) builders and the read-reply parser.</summary>
|
||||
public static class LogixServices
|
||||
{
|
||||
private static CipWriter Start(byte service, ReadOnlySpan<byte> path) =>
|
||||
new CipWriter().U8(service).U8((byte)(path.Length / 2)).Bytes(path);
|
||||
|
||||
public static byte[] ReadTag(ReadOnlySpan<byte> path, ushort elements) => Start(0x4C, path).U16(elements).ToArray();
|
||||
|
||||
public static byte[] ReadTagFragmented(ReadOnlySpan<byte> path, ushort elements, uint offset) =>
|
||||
Start(0x52, path).U16(elements).U32(offset).ToArray();
|
||||
|
||||
public static byte[] WriteTag(ReadOnlySpan<byte> path, ushort type, ushort? handle, ushort elements, ReadOnlySpan<byte> data)
|
||||
{
|
||||
var w = Start(0x4D, path).U16(type);
|
||||
if (type == LogixTypeCode.Struct) w.U16(handle ?? throw new ArgumentException("Struct write needs a handle."));
|
||||
return w.U16(elements).Bytes(data).ToArray();
|
||||
}
|
||||
|
||||
public static byte[] WriteTagFragmented(ReadOnlySpan<byte> path, ushort type, ushort? handle, ushort elements, uint offset, ReadOnlySpan<byte> data)
|
||||
{
|
||||
var w = Start(0x53, path).U16(type);
|
||||
if (type == LogixTypeCode.Struct) w.U16(handle ?? throw new ArgumentException("Struct write needs a handle."));
|
||||
return w.U16(elements).U32(offset).Bytes(data).ToArray();
|
||||
}
|
||||
|
||||
public static (ushort TypeCode, ushort? StructHandle, ReadOnlyMemory<byte> Data) ParseReadReply(CipReply reply)
|
||||
{
|
||||
if (reply.GeneralStatus is not (0x00 or 0x06))
|
||||
throw new AbTransportException(reply.Status, $"Read failed: general 0x{reply.GeneralStatus:X2}.");
|
||||
var r = new CipReader(reply.Data.Span);
|
||||
var type = r.U16();
|
||||
ushort? handle = type == LogixTypeCode.Struct ? r.U16() : null;
|
||||
return (type, handle, reply.Data[r.Position..]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
|
||||
|
||||
/// <summary>
|
||||
/// Logix tag read/write over a connection's <see cref="RequestScheduler"/>, including the fragment
|
||||
/// loops for values that do not fit in one frame.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reads are issued as Read Tag Fragmented (0x52) starting at offset 0 — exactly what libplctag puts
|
||||
/// on the wire (golden captures, Task 4) — so the native and libplctag frames stay byte-comparable,
|
||||
/// and the continuation requests are the same service at a non-zero offset.
|
||||
/// </remarks>
|
||||
public sealed class LogixTagClient(RequestScheduler scheduler, ICipChannel channel)
|
||||
{
|
||||
private const byte StatusSuccess = 0x00;
|
||||
private const byte StatusPartial = 0x06;
|
||||
|
||||
/// <summary>Fixed bytes of a Write Tag Fragmented request besides path and data: service, path
|
||||
/// size, type, struct handle, element count, offset.</summary>
|
||||
private const int FragmentedWriteOverhead = 12;
|
||||
|
||||
/// <summary>Upper bound on one read's accumulated data, so a controller that keeps answering
|
||||
/// Partial (0x06) cannot grow memory without limit. Far above any real Logix tag.</summary>
|
||||
internal const int MaxReadBytes = 2 * 1024 * 1024;
|
||||
|
||||
/// <summary>Chunk alignment when one element is wider than a frame (Logix lays structures out on
|
||||
/// at most 8-byte boundaries).</summary>
|
||||
private const int WideElementAlignment = 8;
|
||||
|
||||
private readonly RequestScheduler _scheduler = scheduler ?? throw new ArgumentNullException(nameof(scheduler));
|
||||
private readonly ICipChannel _channel = channel ?? throw new ArgumentNullException(nameof(channel));
|
||||
|
||||
/// <summary>
|
||||
/// Read <paramref name="elements"/> elements of the tag at the encoded <paramref name="path"/>.
|
||||
/// A Partial (0x06) reply keeps its data and the read continues at <c>offset = bytes received so
|
||||
/// far</c> until a reply reports success. The type code and struct handle come from the first
|
||||
/// reply; each fragment reply's repeated type header is stripped before its data is appended.
|
||||
/// </summary>
|
||||
/// <remarks>Packing bounds only the request size, so a packed batch of large reads can come back
|
||||
/// with 0x06 per service; that is expected, and this loop recovers it.</remarks>
|
||||
public async Task<(ushort TypeCode, ushort? StructHandle, byte[] Data)> ReadAsync(
|
||||
byte[] path, ushort elements, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
var reply = await _scheduler.SendAsync(LogixServices.ReadTagFragmented(path, elements, 0), ct).ConfigureAwait(false);
|
||||
var (type, handle, first) = LogixServices.ParseReadReply(reply); // throws on any status but 0x00/0x06
|
||||
var data = new List<byte>(first.Length);
|
||||
data.AddRange(first.Span);
|
||||
var lastChunk = first.Length;
|
||||
|
||||
while (reply.GeneralStatus == StatusPartial)
|
||||
{
|
||||
if (lastChunk == 0)
|
||||
throw new AbTransportException(AbStatus.BadReply, "Partial read reply carried no data; the fragment loop cannot progress.");
|
||||
reply = await _scheduler.SendAsync(
|
||||
LogixServices.ReadTagFragmented(path, elements, (uint)data.Count), ct).ConfigureAwait(false);
|
||||
var (fragmentType, _, chunk) = LogixServices.ParseReadReply(reply);
|
||||
if (fragmentType != type)
|
||||
throw new AbTransportException(AbStatus.BadReply,
|
||||
$"Read fragment type 0x{fragmentType:X4} differs from the first reply's 0x{type:X4}.");
|
||||
data.AddRange(chunk.Span);
|
||||
lastChunk = chunk.Length;
|
||||
if (data.Count > MaxReadBytes)
|
||||
throw new AbTransportException(AbStatus.TooLarge,
|
||||
$"Fragmented read exceeded {MaxReadBytes} bytes; the controller kept answering Partial.");
|
||||
}
|
||||
|
||||
return (type, handle, data.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write <paramref name="data"/> to the tag at <paramref name="path"/>. One Write Tag when the
|
||||
/// request fits <see cref="ICipChannel.MaxRequestBytes"/>; otherwise Write Tag Fragmented chunks of
|
||||
/// <c>MaxRequestBytes - 12 - path.Length</c> bytes aligned down to the element width (or to 8 bytes
|
||||
/// when a single element is wider than a frame).
|
||||
/// </summary>
|
||||
/// <remarks>A fragmented write is NOT atomic across fragments — the controller applies each
|
||||
/// fragment as it arrives, so a reader can observe a partly-written value. libplctag behaves the
|
||||
/// same way.</remarks>
|
||||
/// <exception cref="ArgumentException">For an atomic type, <paramref name="data"/> is not exactly
|
||||
/// <paramref name="elements"/> × the type's width.</exception>
|
||||
public async Task WriteAsync(byte[] path, ushort type, ushort? handle, ushort elements,
|
||||
ReadOnlyMemory<byte> data, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
var atomicWidth = LogixTypeCode.ByteWidth(type);
|
||||
if (atomicWidth > 0 && data.Length != elements * atomicWidth)
|
||||
throw new ArgumentException(
|
||||
$"Write of {elements} element(s) of type 0x{type:X4} needs {elements * atomicWidth} bytes, got {data.Length}.",
|
||||
nameof(data));
|
||||
var max = _channel.MaxRequestBytes;
|
||||
var isStruct = type == LogixTypeCode.Struct;
|
||||
var singleSize = 2 + path.Length + 2 + (isStruct ? 2 : 0) + 2 + data.Length;
|
||||
if (singleSize <= max)
|
||||
{
|
||||
var reply = await _scheduler.SendAsync(LogixServices.WriteTag(path, type, handle, elements, data.Span), ct).ConfigureAwait(false);
|
||||
EnsureSuccess(reply);
|
||||
return;
|
||||
}
|
||||
|
||||
var chunkSize = AlignedChunkSize(max - FragmentedWriteOverhead - path.Length, ElementWidth(type, elements, data.Length));
|
||||
for (var offset = 0; offset < data.Length; offset += chunkSize)
|
||||
{
|
||||
var length = Math.Min(chunkSize, data.Length - offset);
|
||||
var request = LogixServices.WriteTagFragmented(path, type, handle, elements, (uint)offset, data.Span.Slice(offset, length));
|
||||
var reply = await _scheduler.SendAsync(request, ct).ConfigureAwait(false);
|
||||
EnsureSuccess(reply);
|
||||
}
|
||||
}
|
||||
|
||||
private static int ElementWidth(ushort type, ushort elements, int dataLength)
|
||||
{
|
||||
var width = LogixTypeCode.ByteWidth(type);
|
||||
if (width > 0) return width;
|
||||
return elements > 0 ? Math.Max(1, dataLength / elements) : 1;
|
||||
}
|
||||
|
||||
private static int AlignedChunkSize(int room, int width)
|
||||
{
|
||||
// A structure element can be wider than one frame. Write Tag Fragmented's offset is a byte
|
||||
// offset and the controller reassembles, so this is allowed: align to 8 bytes instead of
|
||||
// refusing the write.
|
||||
var alignment = width <= room ? width : WideElementAlignment;
|
||||
var chunk = room / alignment * alignment;
|
||||
if (chunk <= 0)
|
||||
throw new AbTransportException(AbStatus.TooLarge,
|
||||
$"No room for write data: MaxRequestBytes leaves {room} byte(s) after the path.");
|
||||
return chunk;
|
||||
}
|
||||
|
||||
private static void EnsureSuccess(CipReply reply)
|
||||
{
|
||||
if (reply.GeneralStatus != StatusSuccess)
|
||||
throw new AbTransportException(reply.Status, $"Write failed: general 0x{reply.GeneralStatus:X2}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
|
||||
|
||||
public static class LogixTypeCode
|
||||
{
|
||||
public const ushort Bool = 0xC1, SInt = 0xC2, Int = 0xC3, DInt = 0xC4, LInt = 0xC5, USInt = 0xC6,
|
||||
UInt = 0xC7, UDInt = 0xC8, ULInt = 0xC9, Real = 0xCA, LReal = 0xCB, BoolArray = 0xD3, Struct = 0x02A0;
|
||||
|
||||
/// <summary>Byte width of an atomic type, or -1 when the code is not atomic (e.g. <see cref="Struct"/>).</summary>
|
||||
public static int ByteWidth(ushort code) => code switch
|
||||
{
|
||||
Bool or SInt or USInt => 1,
|
||||
Int or UInt => 2,
|
||||
DInt or UDInt or Real or BoolArray => 4,
|
||||
LInt or ULInt or LReal => 8,
|
||||
_ => -1,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Text;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
|
||||
|
||||
/// <summary>Symbol Object (class 0x6B) instance listing — the <c>@tags</c> enumeration.</summary>
|
||||
public static class SymbolObjectServices
|
||||
{
|
||||
/// <summary>Request attributes: 2 = type, 7 = element length, 8 = array dims, 1 = name. Identical
|
||||
/// to what libplctag sends (<c>logix-tags-listing.hex</c>).</summary>
|
||||
public static byte[] GetInstanceAttributeList(string? programName, uint startInstance)
|
||||
{
|
||||
var path = new CipWriter();
|
||||
if (!string.IsNullOrEmpty(programName))
|
||||
{
|
||||
var a = Encoding.ASCII.GetBytes(programName.StartsWith("Program:", StringComparison.Ordinal) ? programName : "Program:" + programName);
|
||||
path.U8(0x91).U8((byte)a.Length).Bytes(a).PadToEven();
|
||||
}
|
||||
path.U8(0x20).U8(0x6B);
|
||||
// 16-bit instance segment even for small values (libplctag's form, see capture); 32-bit above 0xFFFF.
|
||||
if (startInstance <= 0xFFFF) path.U8(0x25).U8(0).U16((ushort)startInstance);
|
||||
else path.U8(0x26).U8(0).U32(startInstance);
|
||||
var p = path.ToArray();
|
||||
return new CipWriter().U8(0x55).U8((byte)(p.Length / 2)).Bytes(p)
|
||||
.U16(4).U16(2).U16(7).U16(8).U16(1).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Entries are <c>u32 instance, u16 type, u16 elemLen, u32×3 dims, u16 nameLen, name</c>.
|
||||
/// <c>LastInstance</c> is the last entry's instance id (next start = LastInstance + 1).
|
||||
/// <c>More</c> is true on general status 0x06 (partial transfer).</summary>
|
||||
public static (byte[] Entries, uint LastInstance, bool More) ParseListingReply(CipReply reply)
|
||||
{
|
||||
if (reply.GeneralStatus is not (0x00 or 0x06))
|
||||
throw new AbTransportException(reply.Status, $"Symbol listing failed: general 0x{reply.GeneralStatus:X2}.");
|
||||
var span = reply.Data.Span;
|
||||
var r = new CipReader(span);
|
||||
uint last = 0;
|
||||
while (r.Remaining > 0)
|
||||
{
|
||||
last = r.U32();
|
||||
r.Bytes(2 + 2 + 12);
|
||||
var nameLen = r.U16();
|
||||
r.Bytes(nameLen);
|
||||
}
|
||||
var more = reply.GeneralStatus == 0x06;
|
||||
if (more && span.IsEmpty)
|
||||
throw new AbTransportException(AbStatus.BadReply, "Symbol listing reported more data but returned no entries.");
|
||||
return (span.ToArray(), last, more);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
|
||||
|
||||
public sealed record TemplateAttributes(uint DefinitionWords, uint StructureBytes, ushort Members, ushort Handle);
|
||||
|
||||
/// <summary>Template Object (class 0x6C) attribute query and definition read.</summary>
|
||||
public static class TemplateObjectServices
|
||||
{
|
||||
/// <summary>Get Attribute List for attributes 4 (definition words), 5 (structure bytes),
|
||||
/// 2 (member count) and 1 (handle).</summary>
|
||||
public static byte[] GetAttributes(ushort templateId) =>
|
||||
new CipWriter().U8(0x03).U8(3).Bytes([0x20, 0x6C, 0x25, 0x00]).U16(templateId)
|
||||
.U16(4).U16(4).U16(5).U16(2).U16(1).ToArray();
|
||||
|
||||
public static TemplateAttributes ParseAttributes(CipReply reply)
|
||||
{
|
||||
if (reply.GeneralStatus != 0)
|
||||
throw new AbTransportException(reply.Status, $"Template attributes failed: general 0x{reply.GeneralStatus:X2}.");
|
||||
var r = new CipReader(reply.Data.Span);
|
||||
var count = r.U16();
|
||||
uint defWords = 0, structBytes = 0; ushort members = 0, handle = 0;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var id = r.U16(); var status = r.U16();
|
||||
if (status != 0)
|
||||
throw new AbTransportException(AbStatus.BadReply, $"Template attribute {id} status 0x{status:X4}.");
|
||||
switch (id)
|
||||
{
|
||||
case 4: defWords = r.U32(); break;
|
||||
case 5: structBytes = r.U32(); break;
|
||||
case 2: members = r.U16(); break;
|
||||
case 1: handle = r.U16(); break;
|
||||
default: throw new AbTransportException(AbStatus.BadReply, $"Unexpected template attribute {id}.");
|
||||
}
|
||||
}
|
||||
return new TemplateAttributes(defWords, structBytes, members, handle);
|
||||
}
|
||||
|
||||
/// <summary>Read Template: <c>4C 03 20 6C 25 00 id offset bytes</c>.</summary>
|
||||
public static byte[] ReadTemplate(ushort templateId, uint offset, ushort bytes) =>
|
||||
new CipWriter().U8(0x4C).U8(3).Bytes([0x20, 0x6C, 0x25, 0x00]).U16(templateId).U32(offset).U16(bytes).ToArray();
|
||||
|
||||
/// <summary>More ⇔ general status 0x06.</summary>
|
||||
public static bool ReadHasMore(CipReply reply) => reply.GeneralStatus == 0x06;
|
||||
|
||||
/// <summary>The buffer <c>CipTemplateObjectDecoder</c> expects: <c>u16 id, u32 defWords,
|
||||
/// u32 structBytes, u16 members, u16 handle</c> then the raw template bytes.</summary>
|
||||
public static byte[] BuildDecoderBuffer(ushort templateId, TemplateAttributes attrs, ReadOnlySpan<byte> rawTemplate) =>
|
||||
new CipWriter().U16(templateId).U32(attrs.DefinitionWords).U32(attrs.StructureBytes)
|
||||
.U16(attrs.Members).U16(attrs.Handle).Bytes(rawTemplate).ToArray();
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
|
||||
|
||||
/// <summary>Transport-side PCCC data-table address. Independent of the driver's AbLegacyAddress.</summary>
|
||||
/// <param name="FileLetter">File letter(s): N, F, B, T, C, R, ST, L, A, I, O, S.</param>
|
||||
/// <param name="FileNumber">Explicit file number; null for O/I/S (implicit 0/1/2).</param>
|
||||
/// <param name="Element">Element (word) number.</param>
|
||||
/// <param name="SubElement">Word offset within a structured element (T/C: PRE=1, ACC=2; R: LEN=1, POS=2); null = 0.</param>
|
||||
/// <param name="Bit">Bit within the addressed word (<c>/n</c> or a status-bit sub-element), else null.</param>
|
||||
public sealed record PcccAddress(string FileLetter, int? FileNumber, int Element, int? SubElement, int? Bit = null)
|
||||
{
|
||||
/// <summary>The PCCC file-type byte; throws Unsupported for an unknown letter.</summary>
|
||||
public byte FileTypeCode =>
|
||||
PcccFileType.CodeFor(FileLetter)
|
||||
?? throw new AbTransportException(AbStatus.Unsupported, $"Unknown PCCC file type '{FileLetter}'.");
|
||||
|
||||
/// <summary>Explicit file number, else the implicit one for O/I/S.</summary>
|
||||
public int EffectiveFileNumber =>
|
||||
FileNumber ?? PcccFileType.ImplicitFileNumber(FileLetter)
|
||||
?? throw new AbTransportException(AbStatus.Unsupported, $"File type '{FileLetter}' needs an explicit file number.");
|
||||
|
||||
/// <summary>The word offset to read within the element, and the bit within that word if any.</summary>
|
||||
public (int Word, int? Bit) ResolveSubElement() => (SubElement ?? 0, Bit);
|
||||
|
||||
/// <summary>Builds an address from a sub-element mnemonic (PRE, ACC, LEN, POS, or a status bit such as DN).</summary>
|
||||
public static PcccAddress WithSubElementName(string letter, int? fileNumber, int element, string subName)
|
||||
{
|
||||
var l = letter.ToUpperInvariant();
|
||||
var s = subName.ToUpperInvariant();
|
||||
(int Word, int? Bit)? r = (l, s) switch
|
||||
{
|
||||
("T" or "C", "PRE") => (1, null),
|
||||
("T" or "C", "ACC") => (2, null),
|
||||
("R", "LEN") => (1, null),
|
||||
("R", "POS") => (2, null),
|
||||
("T", "EN") => (0, 15),
|
||||
("T", "TT") => (0, 14),
|
||||
("T", "DN") => (0, 13),
|
||||
("C", "CU") => (0, 15),
|
||||
("C", "CD") => (0, 14),
|
||||
("C", "DN") => (0, 13),
|
||||
("C", "OV") => (0, 12),
|
||||
("C", "UN") => (0, 11),
|
||||
("C", "UA") => (0, 10),
|
||||
("R", "EN") => (0, 15),
|
||||
("R", "EU") => (0, 14),
|
||||
("R", "DN") => (0, 13),
|
||||
("R", "EM") => (0, 12),
|
||||
("R", "ER") => (0, 11),
|
||||
("R", "UL") => (0, 10),
|
||||
("R", "IN") => (0, 9),
|
||||
("R", "FD") => (0, 8),
|
||||
_ => null,
|
||||
};
|
||||
if (r is not { } v)
|
||||
throw new AbTransportException(AbStatus.Unsupported, $"Unknown sub-element '{subName}' for file type '{letter}'.");
|
||||
return new PcccAddress(l, fileNumber, element, v.Word, v.Bit);
|
||||
}
|
||||
|
||||
/// <summary>Parses <c>N7:0</c>, <c>B3:1/4</c>, <c>T4:0.ACC</c>, <c>I:0/1</c>, <c>ST9:2</c>; null when malformed.</summary>
|
||||
public static PcccAddress? TryParse(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
var src = text.Trim();
|
||||
int? bit = null;
|
||||
var slash = src.IndexOf('/');
|
||||
if (slash >= 0)
|
||||
{
|
||||
if (!int.TryParse(src[(slash + 1)..], out var b) || b < 0) return null;
|
||||
bit = b;
|
||||
src = src[..slash];
|
||||
}
|
||||
string? sub = null;
|
||||
var dot = src.LastIndexOf('.');
|
||||
if (dot >= 0)
|
||||
{
|
||||
sub = src[(dot + 1)..];
|
||||
if (sub.Length == 0 || !sub.All(char.IsLetter)) return null;
|
||||
src = src[..dot];
|
||||
}
|
||||
var colon = src.IndexOf(':');
|
||||
if (colon <= 0 || !int.TryParse(src[(colon + 1)..], out var element) || element < 0) return null;
|
||||
var filePart = src[..colon];
|
||||
if (!char.IsLetter(filePart[0])) return null;
|
||||
var end = 1;
|
||||
while (end < filePart.Length && char.IsLetter(filePart[end])) end++;
|
||||
var letter = filePart[..end].ToUpperInvariant();
|
||||
if (PcccFileType.CodeFor(letter) is null) return null;
|
||||
int? file = null;
|
||||
if (end < filePart.Length)
|
||||
{
|
||||
if (!int.TryParse(filePart[end..], out var fn) || fn < 0) return null;
|
||||
file = fn;
|
||||
}
|
||||
var implicitFile = PcccFileType.ImplicitFileNumber(letter);
|
||||
if (implicitFile is not null && file is not null) return null;
|
||||
if (implicitFile is null && file is null) return null;
|
||||
if (sub is not null && bit is not null) return null;
|
||||
if (bit is int bb)
|
||||
{
|
||||
// Same limits as AbLegacyAddress: 16-bit words N/B/I/O/S/A 0-15, L 0-31; no bits on F/T/C/R/ST.
|
||||
var max = letter switch { "L" => 31, "N" or "B" or "I" or "O" or "S" or "A" => 15, _ => -1 };
|
||||
if (bb > max) return null;
|
||||
}
|
||||
if (sub is null) return new PcccAddress(letter, file, element, null, bit);
|
||||
try { return WithSubElementName(letter, file, element, sub); }
|
||||
catch (AbTransportException) { return null; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
|
||||
|
||||
/// <summary>PCCC file-type codes for the protected typed logical read/write commands (0xA2/0xAA).
|
||||
/// <para>Wire evidence (final review I3), all against the <c>ab_server</c> simulator, never a real PLC:
|
||||
/// N (0x89) — byte parity with libplctag captures (SLC 500 read + write, MicroLogix read, PLC-5 read) and
|
||||
/// native ab_server round trips (N7:0 read on all three profiles, N7:5 write on SLC 500); F (0x8A) — one
|
||||
/// SLC 500 F8 read capture only (no native write on a wire).</para>
|
||||
/// <para>S, B, T, C, R, ST, A, L, O and I are encoded from the PCCC data-table file-type table and are
|
||||
/// UNVERIFIED natively — no capture and no simulator test. That includes S:0, the default probe address,
|
||||
/// and the T/C/R sub-element (PRE/ACC/LEN/POS) word offsets.</para></summary>
|
||||
public static class PcccFileType
|
||||
{
|
||||
/// <summary>Returns the file-type code for a file letter (case-insensitive), or null when unknown.</summary>
|
||||
public static byte? CodeFor(string fileLetter) => fileLetter.ToUpperInvariant() switch
|
||||
{
|
||||
"O" => 0x8B,
|
||||
"I" => 0x8C,
|
||||
"S" => 0x84,
|
||||
"B" => 0x85,
|
||||
"T" => 0x86,
|
||||
"C" => 0x87,
|
||||
"R" => 0x88,
|
||||
"N" => 0x89,
|
||||
"F" => 0x8A,
|
||||
"ST" => 0x8D,
|
||||
"A" => 0x8E,
|
||||
"L" => 0x91,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>The implicit file number for the single-letter system files (O=0, I=1, S=2), else null.</summary>
|
||||
public static int? ImplicitFileNumber(string fileLetter) => fileLetter.ToUpperInvariant() switch
|
||||
{
|
||||
"O" => 0,
|
||||
"I" => 1,
|
||||
"S" => 2,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
|
||||
|
||||
/// <summary>Pure builders/parsers for Execute PCCC (CIP 0x4B on class 0x67) carrying SLC/MicroLogix
|
||||
/// protected typed logical commands and PLC-5 range reads. Framing is taken from the golden captures.
|
||||
/// The Execute PCCC request is sent bare (no Unconnected Send, no route) per the captures.</summary>
|
||||
public static class PcccServices
|
||||
{
|
||||
/// <summary>Largest data byte count one typed read/write may carry (single-byte count on the wire).</summary>
|
||||
public const int MaxDataBytes = 236;
|
||||
|
||||
/// <summary>True: PLC-5 range READ (cmd 0x0F fnc 0x01) is capture-verified.</summary>
|
||||
public static bool Plc5Verified { get; } = true;
|
||||
|
||||
/// <summary>False: no PLC-5 write capture exists, so no PLC-5 write builder ships.</summary>
|
||||
public static bool Plc5WriteVerified { get; } = false;
|
||||
|
||||
/// <summary>Requestor vendor id the native transport puts in Execute PCCC: libplctag's, from the captures
|
||||
/// (<c>07 3D F3 45 43 50 21</c> in <c>slc500-read-n7.hex</c>).</summary>
|
||||
/// <remarks>Not the CIP originator vendor (<see cref="ConnectionOptions.VendorId"/>). ab_server answers every
|
||||
/// PCCC request with this fixed requestor id (<c>PCCC_RESP_PREFIX</c> in its <c>pccc.c</c>) instead of
|
||||
/// echoing the request's, while real controllers echo whatever was sent. Sending libplctag's identity is
|
||||
/// the only choice that lets the echo check in <see cref="ParseReply"/> pass against both (found by
|
||||
/// Task 17 slc500: with the 0x1337 originator identity every native PCCC reply was BadReply).</remarks>
|
||||
public const ushort RequestorVendor = 0xF33D;
|
||||
|
||||
/// <summary>Requestor serial number paired with <see cref="RequestorVendor"/> (libplctag's, from the captures).</summary>
|
||||
public const uint RequestorSerial = 0x21504345;
|
||||
|
||||
/// <summary>Wraps a PCCC command in Execute PCCC: <c>4B 02 20 67 24 01</c> + requestor id (7, vendor, serial) + PCCC.</summary>
|
||||
public static byte[] ExecutePccc(ushort requestorVendor, uint requestorSerial, ReadOnlySpan<byte> pccc) =>
|
||||
new CipWriter().U8(0x4B).U8(2).U8(0x20).U8(0x67).U8(0x24).U8(0x01)
|
||||
.U8(7).U16(requestorVendor).U32(requestorSerial).Bytes(pccc).ToArray();
|
||||
|
||||
private static void Field(CipWriter w, int v)
|
||||
{
|
||||
if (v < 0) throw new AbTransportException(AbStatus.OutOfBounds, $"Negative PCCC field {v}.");
|
||||
if (v > ushort.MaxValue) throw new AbTransportException(AbStatus.OutOfBounds, $"PCCC field {v} exceeds 65535.");
|
||||
if (v < 0xFF) w.U8((byte)v); else w.U8(0xFF).U16((ushort)v);
|
||||
}
|
||||
|
||||
private static void CheckCount(int n)
|
||||
{
|
||||
if (n < 0 || n > MaxDataBytes)
|
||||
throw new AbTransportException(AbStatus.TooLarge, $"PCCC byte count {n} exceeds {MaxDataBytes}.");
|
||||
}
|
||||
|
||||
private static void Logical(CipWriter w, PcccAddress a)
|
||||
{
|
||||
Field(w, a.EffectiveFileNumber);
|
||||
w.U8(a.FileTypeCode);
|
||||
Field(w, a.Element);
|
||||
Field(w, a.ResolveSubElement().Word);
|
||||
}
|
||||
|
||||
/// <summary>Protected typed logical read with 3 address fields (0x0F/0xA2).</summary>
|
||||
public static byte[] SlcTypedRead(ushort tns, PcccAddress addr, int byteCount)
|
||||
{
|
||||
CheckCount(byteCount);
|
||||
var w = new CipWriter().U8(0x0F).U8(0).U16(tns).U8(0xA2).U8((byte)byteCount);
|
||||
Logical(w, addr);
|
||||
return w.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Protected typed logical write with 3 address fields (0x0F/0xAA). SLC/MicroLogix/LogixPccc
|
||||
/// only: it must not be used for PLC-5 (the family guard lives in the AbLegacy native runtime, Task 14).</summary>
|
||||
public static byte[] SlcTypedWrite(ushort tns, PcccAddress addr, ReadOnlySpan<byte> data)
|
||||
{
|
||||
CheckCount(data.Length);
|
||||
var w = new CipWriter().U8(0x0F).U8(0).U16(tns).U8(0xAA).U8((byte)data.Length);
|
||||
Logical(w, addr);
|
||||
return w.Bytes(data).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>PLC-5 range read (0x0F/0x01), as captured from libplctag:
|
||||
/// <c>offset u16 (0), totalTrans u16, levelMask u8 (0x06 = file+element), file*, element*, byteCount u8</c>.
|
||||
/// <c>totalTrans</c> counts 16-bit WORDS (DF1 word range read; the capture shows 1 for one 2-byte N element).
|
||||
/// Only N-file reads are capture-verified, other PLC-5 file types are unverified; sub-element addressing is not and throws Unsupported.</summary>
|
||||
public static byte[] Plc5RangeRead(ushort tns, PcccAddress addr, ushort totalTrans, int byteCount)
|
||||
{
|
||||
CheckCount(byteCount);
|
||||
if (addr.ResolveSubElement().Word != 0)
|
||||
throw new AbTransportException(AbStatus.Unsupported, "PLC-5 sub-element addressing is not capture-verified.");
|
||||
var w = new CipWriter().U8(0x0F).U8(0).U16(tns).U8(0x01).U16(0).U16(totalTrans).U8(0x06);
|
||||
Field(w, addr.EffectiveFileNumber);
|
||||
Field(w, addr.Element);
|
||||
return w.U8((byte)byteCount).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Validates an Execute PCCC reply and returns the PCCC data bytes.</summary>
|
||||
/// <param name="reply">The parsed CIP reply.</param>
|
||||
/// <param name="expectedTns">The request's TNS; checked before STS so a stale error reply is BadReply.</param>
|
||||
/// <param name="expectedRequestorId">Optional full echoed requestor id (length byte included) to compare.</param>
|
||||
public static ReadOnlyMemory<byte> ParseReply(CipReply reply, ushort expectedTns, byte[]? expectedRequestorId = null)
|
||||
{
|
||||
if (reply.GeneralStatus != 0)
|
||||
throw new AbTransportException(reply.Status, $"Execute PCCC CIP status 0x{reply.GeneralStatus:X2}.");
|
||||
var r = new CipReader(reply.Data.Span);
|
||||
var idLen = r.U8();
|
||||
if (idLen < 1) throw new AbTransportException(AbStatus.BadReply, "Empty requestor id in PCCC reply.");
|
||||
var idBody = r.Bytes(idLen - 1);
|
||||
if (expectedRequestorId is not null
|
||||
&& !(expectedRequestorId.Length == idLen && expectedRequestorId[0] == idLen
|
||||
&& idBody.SequenceEqual(expectedRequestorId.AsSpan(1))))
|
||||
throw new AbTransportException(AbStatus.BadReply, "PCCC reply requestor id does not match the request.");
|
||||
var cmd = r.U8();
|
||||
if (cmd != 0x4F)
|
||||
throw new AbTransportException(AbStatus.BadReply, $"PCCC reply command 0x{cmd:X2} != 0x4F.");
|
||||
var sts = r.U8();
|
||||
var tns = r.U16();
|
||||
if (tns != expectedTns)
|
||||
throw new AbTransportException(AbStatus.BadReply, $"PCCC TNS {tns} != {expectedTns}.");
|
||||
byte? ext = sts == 0xF0 ? r.U8() : null;
|
||||
if (sts != 0)
|
||||
throw new AbTransportException(AbStatusCodes.FromPccc(sts, ext),
|
||||
$"PCCC STS 0x{sts:X2}{(ext is { } e ? $" EXT 0x{e:X2}" : "")}.");
|
||||
return reply.Data[r.Position..];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Buffers.Binary;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||||
|
||||
/// <summary>Bounds-checked little-endian reader. Any overrun throws AbTransportException(BadReply).</summary>
|
||||
public ref struct CipReader(ReadOnlySpan<byte> data)
|
||||
{
|
||||
private readonly ReadOnlySpan<byte> _data = data;
|
||||
public int Position { get; private set; }
|
||||
public readonly int Remaining => _data.Length - Position;
|
||||
|
||||
private ReadOnlySpan<byte> Take(int n)
|
||||
{
|
||||
if (n < 0 || Position + n > _data.Length)
|
||||
throw new AbTransportException(AbStatus.BadReply, $"Reply truncated: need {n} bytes at {Position}, have {Remaining}.");
|
||||
var s = _data.Slice(Position, n);
|
||||
Position += n;
|
||||
return s;
|
||||
}
|
||||
|
||||
public byte U8() => Take(1)[0];
|
||||
public ushort U16() => BinaryPrimitives.ReadUInt16LittleEndian(Take(2));
|
||||
public uint U32() => BinaryPrimitives.ReadUInt32LittleEndian(Take(4));
|
||||
public ulong U64() => BinaryPrimitives.ReadUInt64LittleEndian(Take(8));
|
||||
public ReadOnlySpan<byte> Bytes(int n) => Take(n);
|
||||
public ReadOnlySpan<byte> Rest() => Take(Remaining);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Buffers.Binary;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||||
|
||||
/// <summary>Growable little-endian writer for CIP / EIP frames.</summary>
|
||||
public sealed class CipWriter
|
||||
{
|
||||
private byte[] _buf = new byte[256];
|
||||
public int Length { get; private set; }
|
||||
|
||||
private Span<byte> Take(int n)
|
||||
{
|
||||
if (Length + n > _buf.Length) Array.Resize(ref _buf, Math.Max(_buf.Length * 2, Length + n));
|
||||
var s = _buf.AsSpan(Length, n);
|
||||
Length += n;
|
||||
return s;
|
||||
}
|
||||
|
||||
public CipWriter U8(byte v) { Take(1)[0] = v; return this; }
|
||||
public CipWriter U16(ushort v) { BinaryPrimitives.WriteUInt16LittleEndian(Take(2), v); return this; }
|
||||
public CipWriter U32(uint v) { BinaryPrimitives.WriteUInt32LittleEndian(Take(4), v); return this; }
|
||||
public CipWriter U64(ulong v) { BinaryPrimitives.WriteUInt64LittleEndian(Take(8), v); return this; }
|
||||
public CipWriter Bytes(ReadOnlySpan<byte> v) { v.CopyTo(Take(v.Length)); return this; }
|
||||
public CipWriter PadToEven() { if ((Length & 1) == 1) U8(0); return this; }
|
||||
|
||||
/// <summary>Overwrite a u16 already written at <paramref name="offset"/> (length back-patching).</summary>
|
||||
public void PatchU16(int offset, ushort v) => BinaryPrimitives.WriteUInt16LittleEndian(_buf.AsSpan(offset, 2), v);
|
||||
|
||||
public byte[] ToArray() => _buf.AsSpan(0, Length).ToArray();
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport</RootNamespace>
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Host.Tests"/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -32,6 +32,23 @@ public sealed class AbCipDriverOptions
|
||||
/// </summary>
|
||||
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Which transport stack the driver, its Test Connect probe and the CLI use. <c>null</c> (the
|
||||
/// <c>transport</c> key absent) means "use the default": the node-level
|
||||
/// <c>Drivers:AbCip:DefaultTransport</c> appsetting, which itself defaults to <see cref="AbCipTransport.Native"/>;
|
||||
/// an explicit value always wins. The effective transport is resolved in one place,
|
||||
/// <c>AbCipTransportResolver</c>. Set <see cref="AbCipTransport.Libplctag"/> to fall back.
|
||||
/// <para>Changing this takes effect when the driver instance is RECREATED, not on
|
||||
/// <c>ReinitializeAsync</c>: the factories are chosen once in the constructor and held across
|
||||
/// Shutdown+Initialize. A deploy that changes the driver config stops and respawns the instance
|
||||
/// (<c>DriverSpawnPlanner</c>, #516), so an operator edit does recreate it.</para>
|
||||
/// </summary>
|
||||
/// <remarks>Null is never written: an absent key IS the "use the default" encoding, so a round-trip
|
||||
/// through any serializer (the AdminUI form included) keeps a defaulted driver defaulted rather than
|
||||
/// pinning it to whatever the default happened to be on the day it was saved.</remarks>
|
||||
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
|
||||
public AbCipTransport? Transport { get; init; }
|
||||
|
||||
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
|
||||
public AbCipProbeOptions Probe { get; init; } = new();
|
||||
|
||||
@@ -119,11 +136,9 @@ public sealed class AbCipDriverOptions
|
||||
/// touching every other device on the same family.</param>
|
||||
/// <param name="ConnectionSize">Per-device override for the Forward Open
|
||||
/// ConnectionSize (Large Forward Open packet size in bytes). <c>null</c> inherits the family
|
||||
/// profile's <c>DefaultConnectionSize</c>. Honoured by the driver layer; the underlying
|
||||
/// libplctag 1.5.2 wrapper has no direct <c>ConnectionSize</c> property, so the value is
|
||||
/// plumbed through <see cref="AbCipTagCreateParams"/> for forward-compat with future wrapper
|
||||
/// versions or a custom tag-attribute path; current builds use the family profile default at
|
||||
/// the wire layer regardless.</param>
|
||||
/// profile's <c>DefaultConnectionSize</c>. The native transport honours it on the wire (its Forward
|
||||
/// Open size). The libplctag transport ignores it: the libplctag 1.5.2 wrapper has no
|
||||
/// <c>ConnectionSize</c> property, so libplctag opens with its own size for the <c>plc</c> attribute.</param>
|
||||
public sealed record AbCipDeviceOptions(
|
||||
string HostAddress,
|
||||
AbCipPlcFamily PlcFamily = AbCipPlcFamily.ControlLogix,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Which CIP transport stack an AbCip driver instance (and its Test Connect probe / CLI run)
|
||||
/// talks to the controller through. Serialises as a STRING in driver config JSON.
|
||||
/// </summary>
|
||||
public enum AbCipTransport
|
||||
{
|
||||
/// <summary>The libplctag-backed transport. The fallback; selected explicitly or via the node-level <c>Drivers:AbCip:DefaultTransport</c>.</summary>
|
||||
Libplctag,
|
||||
|
||||
/// <summary>The native managed EtherNet/IP + CIP transport (shared Driver.Ab.Transport stack). The default.</summary>
|
||||
Native,
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using libplctag;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
@@ -137,22 +137,31 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <summary>Initializes a new instance of the <see cref="AbCipDriver"/> class.</summary>
|
||||
/// <param name="options">The driver configuration options.</param>
|
||||
/// <param name="driverInstanceId">A unique identifier for this driver instance.</param>
|
||||
/// <param name="tagFactory">Optional factory for creating tag runtimes; uses libplctag default if null.</param>
|
||||
/// <param name="enumeratorFactory">Optional factory for enumerating tags; uses libplctag default if null.</param>
|
||||
/// <param name="templateReaderFactory">Optional factory for reading UDT templates; uses libplctag default if null.</param>
|
||||
/// <param name="tagFactory">Optional factory for creating tag runtimes; when null the resolved transport's (see <see cref="AbCipTransportResolver"/>) is used.</param>
|
||||
/// <param name="enumeratorFactory">Optional factory for enumerating tags; when null the resolved transport's is used.</param>
|
||||
/// <param name="templateReaderFactory">Optional factory for reading UDT templates; when null the resolved transport's is used.</param>
|
||||
/// <param name="logger">Optional logger; uses null logger if not provided.</param>
|
||||
/// <param name="defaultTransport">The node-level default for an absent <c>transport</c> key
|
||||
/// (<c>Drivers:AbCip:DefaultTransport</c>); null means the compiled <see cref="AbCipTransportResolver.Default"/>.</param>
|
||||
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
|
||||
IAbCipTagFactory? tagFactory = null,
|
||||
IAbCipTagEnumeratorFactory? enumeratorFactory = null,
|
||||
IAbCipTemplateReaderFactory? templateReaderFactory = null,
|
||||
ILogger<AbCipDriver>? logger = null)
|
||||
ILogger<AbCipDriver>? logger = null,
|
||||
AbCipTransport? defaultTransport = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_tagFactory = tagFactory ?? new LibplctagTagFactory();
|
||||
_enumeratorFactory = enumeratorFactory ?? new LibplctagTagEnumeratorFactory();
|
||||
_templateReaderFactory = templateReaderFactory ?? new LibplctagTemplateReaderFactory();
|
||||
// Chosen once here and held across ReinitializeAsync (Shutdown+Initialize): a changed
|
||||
// Transport in a reinit config is inert; a deploy recreates the instance instead.
|
||||
var nodeDefault = defaultTransport ?? AbCipTransportResolver.Default;
|
||||
_transport = AbCipTransportResolver.Resolve(options, nodeDefault);
|
||||
_transportDescription = AbCipTransportResolver.Describe(options.Transport, nodeDefault);
|
||||
var defaults = AbCipTransportFactories.For(_transport);
|
||||
_tagFactory = tagFactory ?? defaults.Tags;
|
||||
_enumeratorFactory = enumeratorFactory ?? defaults.Enumerators;
|
||||
_templateReaderFactory = templateReaderFactory ?? defaults.Templates;
|
||||
_logger = logger ?? NullLogger<AbCipDriver>.Instance;
|
||||
_resolver = new EquipmentTagRefResolver<AbCipTagDefinition>(
|
||||
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
|
||||
@@ -172,6 +181,32 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval, _logger);
|
||||
}
|
||||
|
||||
/// <summary>The tag factory chosen at construction; exposed so tests can pin transport selection.</summary>
|
||||
internal IAbCipTagFactory TagFactory => _tagFactory;
|
||||
|
||||
/// <summary>The effective transport resolved at construction (see <see cref="AbCipTransportResolver"/>).
|
||||
/// Describes the OPTION only: injected factories (tests) are used regardless.</summary>
|
||||
public AbCipTransport EffectiveTransport => _transport;
|
||||
|
||||
// The transport chosen at construction (see the ctor); logged at every init so an operator can tell
|
||||
// from the node log which stack serves this instance.
|
||||
private readonly AbCipTransport _transport;
|
||||
|
||||
// Configured-vs-effective rendering of _transport for the init log, e.g. "Native (default)".
|
||||
private readonly string _transportDescription;
|
||||
|
||||
// Tags whose type-mismatch Warning has already been logged (final review I4): a mis-authored tag is
|
||||
// polled forever, so the Warning is once per tag per driver instance, not once per poll.
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _typeMismatchWarned =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Logs a type-mismatch Warning the first time <paramref name="tagName"/> reports one on this instance.</summary>
|
||||
private void WarnTypeMismatchOnce(string tagName, string message, params object?[] args)
|
||||
{
|
||||
if (_typeMismatchWarned.TryAdd(tagName, 0))
|
||||
_logger.LogWarning(message, args);
|
||||
}
|
||||
|
||||
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
|
||||
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
|
||||
|
||||
@@ -276,6 +311,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
_logger.LogInformation(
|
||||
"AbCip driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}",
|
||||
_driverInstanceId, _transportDescription, _tagFactory.GetType().Name);
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(driverConfigJson))
|
||||
@@ -781,6 +819,19 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
await runtime.ReadAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status == (int)AbStatus.TypeMismatch)
|
||||
{
|
||||
// Final review I4: the controller ANSWERED with a type that disagrees with the authored
|
||||
// one — a deterministic config error, not a comms fault. Keep the handle (evicting would
|
||||
// re-create it — and, for a sole lease holder, re-Forward-Open — on every poll), leave
|
||||
// health alone, warn once per tag.
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
|
||||
WarnTypeMismatchOnce(def.Name,
|
||||
"AbCip read of {Tag} on device {Device}: the controller's type does not match the authored " +
|
||||
"{DataType}; reporting BadTypeMismatch until the tag's dataType is corrected (logged once per tag)",
|
||||
reference, def.DeviceHostAddress, def.DataType);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
if (status != 0)
|
||||
{
|
||||
// Evict the stale handle so the next call re-creates it.
|
||||
@@ -789,12 +840,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// mirrors the probe loop's recreate-on-failure behaviour.
|
||||
EvictRuntime(device, def.Name);
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||||
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
|
||||
AbCipStatusMapper.MapStatus(status), null, now);
|
||||
_logger.LogWarning(
|
||||
"AbCip read returned non-zero libplctag status {LibplctagStatus} for tag {Tag} on device {Device}; " +
|
||||
"AbCip read returned non-zero transport status {Status} for tag {Tag} on device {Device}; " +
|
||||
"evicting cached runtime so next call re-creates it",
|
||||
status, reference, def.DeviceHostAddress);
|
||||
return ReadOutcome.Degraded($"libplctag status {status} reading {reference}");
|
||||
return ReadOutcome.Degraded($"transport status {status} reading {reference}");
|
||||
}
|
||||
|
||||
var tagPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
@@ -808,11 +859,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
&& ExpectedScalarWidth(def.DataType) is { } width
|
||||
&& runtime.GetSize() is var size && size >= 0 && size != width)
|
||||
{
|
||||
// Same class as the native TypeMismatch above (final review I4): deterministic, device
|
||||
// answered — no Degraded, warn once per tag.
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
|
||||
_logger.LogWarning(
|
||||
"AbCip read of {Tag} on device {Device} returned {Size} bytes but its authored type {DataType} is {Width} bytes wide; check the tag's dataType",
|
||||
WarnTypeMismatchOnce(def.Name,
|
||||
"AbCip read of {Tag} on device {Device} returned {Size} bytes but its authored type {DataType} is {Width} bytes wide; check the tag's dataType (logged once per tag)",
|
||||
reference, def.DeviceHostAddress, size, def.DataType, width);
|
||||
return ReadOutcome.Degraded($"type mismatch reading {reference}: {size} bytes for {def.DataType}");
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
// Review I-1 — an array tag (the EXPLICIT IsArray flag) decodes the whole buffer into an
|
||||
// element-typed CLR array (int[]/float[]/bool[]/string[]…), INCLUDING a 1-element array
|
||||
@@ -832,10 +885,25 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
|
||||
{
|
||||
// A deterministic local refusal — not a comms fault: BadNotSupported, keep the handle, no
|
||||
// health signal (Task 22 review r1).
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
|
||||
{
|
||||
// Final review I4: deterministic, keep the handle, no health signal, warn once per tag.
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
|
||||
WarnTypeMismatchOnce(def.Name, "AbCip read of {Tag} on device {Device} refused: {Reason} (logged once per tag)",
|
||||
reference, def.DeviceHostAddress, ate.Message);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Transport exception — evict so the next read creates a fresh handle. A libplctag
|
||||
// exception carries its Status, so a tag the controller does not have reads
|
||||
// Transport exception — evict so the next read creates a fresh handle. An AbTransportException
|
||||
// carries its AbStatus, so a tag the controller does not have reads
|
||||
// BadNodeIdUnknown and a decode past the buffer BadOutOfRange, not a comms error.
|
||||
EvictRuntime(device, def.Name);
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||||
@@ -850,7 +918,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <summary>
|
||||
/// Perform one whole-UDT read on the parent tag, then decode each
|
||||
/// grouped member from the runtime's buffer at its computed byte offset. A per-group
|
||||
/// failure (parent read raised, non-zero libplctag status, or missing device) stamps
|
||||
/// failure (parent read raised, non-zero transport status, or missing device) stamps
|
||||
/// the mapped fault across every grouped member only — sibling groups + the
|
||||
/// per-tag fallback list are unaffected.
|
||||
/// </summary>
|
||||
@@ -878,16 +946,26 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
await runtime.ReadAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status == (int)AbStatus.TypeMismatch)
|
||||
{
|
||||
// Final review I4: deterministic — keep the handle, no health signal, warn once.
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadTypeMismatch);
|
||||
WarnTypeMismatchOnce(parent.Name,
|
||||
"AbCip whole-UDT read of {Parent} on device {Device}: the controller's type does not match " +
|
||||
"the authored type; {MemberCount} members report BadTypeMismatch (logged once per tag)",
|
||||
group.ParentName, parent.DeviceHostAddress, group.Members.Count);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
if (status != 0)
|
||||
{
|
||||
EvictRuntime(device, parent.Name);
|
||||
var mapped = AbCipStatusMapper.MapLibplctagStatus(status);
|
||||
var mapped = AbCipStatusMapper.MapStatus(status);
|
||||
StampGroupStatus(group, results, now, mapped);
|
||||
_logger.LogWarning(
|
||||
"AbCip whole-UDT read returned non-zero libplctag status {LibplctagStatus} for parent {Parent} " +
|
||||
"AbCip whole-UDT read returned non-zero transport status {Status} for parent {Parent} " +
|
||||
"on device {Device}; {MemberCount} member values stamped with mapped status",
|
||||
status, group.ParentName, parent.DeviceHostAddress, group.Members.Count);
|
||||
return ReadOutcome.Degraded($"libplctag status {status} reading UDT {group.ParentName}");
|
||||
return ReadOutcome.Degraded($"transport status {status} reading UDT {group.ParentName}");
|
||||
}
|
||||
|
||||
foreach (var member in group.Members)
|
||||
@@ -906,6 +984,19 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
|
||||
{
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNotSupported);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
|
||||
{
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadTypeMismatch);
|
||||
WarnTypeMismatchOnce(parent.Name,
|
||||
"AbCip whole-UDT read of {Parent} on device {Device} refused: {Reason} (logged once per tag)",
|
||||
group.ParentName, parent.DeviceHostAddress, ate.Message);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
EvictRuntime(device, parent.Name);
|
||||
@@ -989,12 +1080,20 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
opLock.Release();
|
||||
}
|
||||
|
||||
if (status != 0)
|
||||
if (status == (int)AbStatus.TypeMismatch)
|
||||
{
|
||||
// Final review I4: deterministic — keep the handle, leave health, warn once per tag.
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
||||
WarnTypeMismatchOnce(def.Name,
|
||||
"AbCip write to {Tag} on device {Device} refused: the controller's type does not match the authored {DataType} (logged once per tag)",
|
||||
w.FullReference, def.DeviceHostAddress, def.DataType);
|
||||
}
|
||||
else if (status != 0)
|
||||
{
|
||||
EvictRuntime(device, def.Name);
|
||||
results[i] = new WriteResult(AbCipStatusMapper.MapLibplctagStatus(status));
|
||||
results[i] = new WriteResult(AbCipStatusMapper.MapStatus(status));
|
||||
_logger.LogWarning(
|
||||
"AbCip write returned non-zero libplctag status {LibplctagStatus} for tag {Tag} on device {Device}; " +
|
||||
"AbCip write returned non-zero transport status {Status} for tag {Tag} on device {Device}; " +
|
||||
"evicting cached runtime so next call re-creates it",
|
||||
status, w.FullReference, def.DeviceHostAddress);
|
||||
}
|
||||
@@ -1042,6 +1141,25 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
"AbCip write value out of range for tag {Tag} on device {Device}",
|
||||
w.FullReference, def.DeviceHostAddress);
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
|
||||
{
|
||||
// A deterministic local refusal (e.g. the native runtime cannot type the write) — not a comms
|
||||
// fault: BadNotSupported, keep the handle, leave health alone (Task 22 review r1).
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
|
||||
_logger.LogWarning(ate,
|
||||
"AbCip write refused (not supported) for tag {Tag} on device {Device}",
|
||||
w.FullReference, def.DeviceHostAddress);
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
|
||||
{
|
||||
// Final review I4: the controller's type disagrees with the authored one (native type gate, or
|
||||
// CIP 0xFF/0x2107 from the controller) — deterministic: BadTypeMismatch, keep the handle, leave
|
||||
// health alone, warn once per tag. Retrying cannot succeed until the config is corrected.
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
||||
WarnTypeMismatchOnce(def.Name,
|
||||
"AbCip write to {Tag} on device {Device} refused: {Reason} (logged once per tag)",
|
||||
w.FullReference, def.DeviceHostAddress, ate.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Transport / wire error — evict so the next write creates a fresh handle.
|
||||
@@ -1076,7 +1194,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
|
||||
await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
|
||||
var readStatus = parentRuntime.GetStatus();
|
||||
if (readStatus != 0) return AbCipStatusMapper.MapLibplctagStatus(readStatus);
|
||||
if (readStatus != 0) return AbCipStatusMapper.MapStatus(readStatus);
|
||||
|
||||
var current = Convert.ToInt32(parentRuntime.DecodeValue(AbCipDataType.DInt, bitIndex: null) ?? 0);
|
||||
var updated = Convert.ToBoolean(value)
|
||||
@@ -1088,7 +1206,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var writeStatus = parentRuntime.GetStatus();
|
||||
return writeStatus == 0
|
||||
? AbCipStatusMapper.Good
|
||||
: AbCipStatusMapper.MapLibplctagStatus(writeStatus);
|
||||
: AbCipStatusMapper.MapStatus(writeStatus);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -1171,7 +1289,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// path then boxes them into a typed CLR array. Scalar tags pass count 1 + IsArray false.
|
||||
var runtime = _tagFactory.Create(
|
||||
device.BuildCreateParams(
|
||||
parsed.ToLibplctagName(), _options.Timeout, def.ElementCount, IsArrayTag(def)));
|
||||
parsed.ToLibplctagName(), _options.Timeout, def.ElementCount, IsArrayTag(def),
|
||||
authoredType: def.DataType));
|
||||
try
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
@@ -1183,10 +1302,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// not answer. "Tag not found" means the controller replied over a working session; it
|
||||
// is a config error on this one tag, and counting it as a failed Forward-Open throttled
|
||||
// every sibling tag on the device (seen live: one typo blacked out the whole device).
|
||||
if (ex is LibPlcTagException lpe
|
||||
&& AbCipStatusMapper.TryGetStatus(lpe, out var status)
|
||||
&& status != Status.Ok
|
||||
&& AbCipDriverProbe.IsReachableStatus(status))
|
||||
if (ex is AbTransportException ate
|
||||
&& ate.Status != AbStatus.Ok
|
||||
&& AbCipDriverProbe.IsReachableStatus(ate.Status))
|
||||
device.Backoff.RecordSuccess();
|
||||
else
|
||||
device.Backoff.RecordFailure(DateTime.UtcNow);
|
||||
@@ -1211,7 +1329,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// Evict the runtime for <paramref name="tagName"/> from the device's cache and dispose
|
||||
/// it so the next read/write call re-creates and re-initializes a fresh handle.
|
||||
/// Called from <see cref="ReadSingleAsync"/>, <see cref="ReadGroupAsync"/>, and
|
||||
/// <see cref="WriteAsync"/> after a non-zero libplctag status or transport exception —
|
||||
/// <see cref="WriteAsync"/> after a non-zero transport status or transport exception —
|
||||
/// mirroring the probe loop's recreate-on-failure behaviour.
|
||||
/// </summary>
|
||||
private static void EvictRuntime(DeviceState device, string tagName)
|
||||
@@ -1286,7 +1404,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
|
||||
// Controller-discovered tags — opt-in via EnableControllerBrowse. The real @tags
|
||||
// walker (LibplctagTagEnumerator) is the factory default,
|
||||
// walker (SymbolListingEnumerator) is the factory default,
|
||||
// so leaving the flag off keeps the strict-config path for deployments where only
|
||||
// declared tags should appear.
|
||||
if (_options.EnableControllerBrowse && _devices.TryGetValue(device.HostAddress, out var state))
|
||||
@@ -1687,9 +1805,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// length for a 1-D array tag (Phase 4c). Coerced to a minimum of 1.</param>
|
||||
/// <param name="isArray">Review I-1 — the EXPLICIT array signal threaded through so a
|
||||
/// 1-element array (<paramref name="elementCount"/> 1) is still read as an array.</param>
|
||||
/// <param name="authoredType">The tag definition's authored type, for the native runtime's
|
||||
/// returned-type check; null skips it.</param>
|
||||
/// <returns>The computed tag creation parameters.</returns>
|
||||
public AbCipTagCreateParams BuildCreateParams(
|
||||
string tagName, TimeSpan timeout, int elementCount = 1, bool isArray = false) => new(
|
||||
string tagName, TimeSpan timeout, int elementCount = 1, bool isArray = false,
|
||||
AbCipDataType? authoredType = null) => new(
|
||||
Gateway: ParsedAddress.Gateway,
|
||||
Port: ParsedAddress.Port,
|
||||
CipPath: ParsedAddress.CipPath,
|
||||
@@ -1699,7 +1820,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
AllowPacking: Options.AllowPacking ?? Profile.SupportsRequestPacking,
|
||||
ConnectionSize: Options.ConnectionSize ?? Profile.DefaultConnectionSize,
|
||||
ElementCount: elementCount < 1 ? 1 : elementCount,
|
||||
IsArray: isArray);
|
||||
IsArray: isArray,
|
||||
AuthoredType: authoredType,
|
||||
UseConnectedMessaging: Profile.SupportsConnectedMessaging);
|
||||
|
||||
/// <summary>Disposes all runtime tag handles and clears the caches.</summary>
|
||||
public void DisposeHandles()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
@@ -15,13 +16,22 @@ public static class AbCipDriverFactoryExtensions
|
||||
public const string DriverTypeName = "AbCip";
|
||||
|
||||
/// <summary>
|
||||
/// Registers the AB CIP driver factory with the driver registry.
|
||||
/// Registers the AB CIP driver factory with the driver registry. The optional
|
||||
/// <paramref name="loggerFactory"/> is captured at registration time and used to construct an
|
||||
/// <see cref="ILogger{AbCipDriver}"/> per driver instance — without it the driver runs with the
|
||||
/// null logger, so none of its diagnostics (including the per-init transport line) reach the
|
||||
/// node log. Mirrors the AbLegacy / Modbus registration pattern.
|
||||
/// </summary>
|
||||
/// <param name="registry">The driver factory registry to register with.</param>
|
||||
public static void Register(DriverFactoryRegistry registry)
|
||||
/// <param name="loggerFactory">Optional logger factory for driver instances.</param>
|
||||
/// <param name="defaultTransport">The node-level transport for drivers whose config has no
|
||||
/// <c>transport</c> key (<c>Drivers:AbCip:DefaultTransport</c>, final review I1). An explicit
|
||||
/// per-driver key still wins.</param>
|
||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null,
|
||||
AbCipTransport defaultTransport = AbCipTransportResolver.Default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, CreateInstance);
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, defaultTransport));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -31,15 +41,27 @@ public static class AbCipDriverFactoryExtensions
|
||||
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
|
||||
/// <returns>A configured <see cref="AbCipDriver"/> instance.</returns>
|
||||
internal static AbCipDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
|
||||
|
||||
/// <summary>Creates an instance of the AB CIP driver with an optional logger.</summary>
|
||||
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for the driver instance.</param>
|
||||
/// <param name="defaultTransport">The node-level default for an absent <c>transport</c> key.</param>
|
||||
/// <returns>A configured <see cref="AbCipDriver"/> instance.</returns>
|
||||
internal static AbCipDriver CreateInstance(
|
||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory,
|
||||
AbCipTransport defaultTransport = AbCipTransportResolver.Default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
var options = ParseOptions(driverInstanceId, driverConfigJson);
|
||||
return new AbCipDriver(options, driverInstanceId);
|
||||
return new AbCipDriver(options, driverInstanceId, logger: loggerFactory?.CreateLogger<AbCipDriver>(),
|
||||
defaultTransport: defaultTransport);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialise an AB CIP driver-config JSON document into <see cref="AbCipDriverOptions"/>.
|
||||
/// Shared by <see cref="CreateInstance"/> (first construction) and
|
||||
/// Shared by <see cref="CreateInstance(string, string, ILoggerFactory?, AbCipTransport)"/> (first construction) and
|
||||
/// <see cref="AbCipDriver.InitializeAsync"/> / <see cref="AbCipDriver.ReinitializeAsync"/>
|
||||
/// so a reinitialize with a changed config JSON (new device, new tag, changed timeout)
|
||||
/// actually takes effect rather than being silently discarded.
|
||||
@@ -81,6 +103,11 @@ public static class AbCipDriverFactoryExtensions
|
||||
ProbeTagPath = dto.Probe?.ProbeTagPath,
|
||||
},
|
||||
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
|
||||
// Absent ⇒ null ("use the default", resolved by AbCipTransportResolver); a bad value still
|
||||
// fails exactly as any other bad enum field does.
|
||||
Transport = string.IsNullOrWhiteSpace(dto.Transport)
|
||||
? null
|
||||
: ParseEnum<AbCipTransport>(dto.Transport, tagName: null, driverInstanceId, "Transport"),
|
||||
EnableControllerBrowse = dto.EnableControllerBrowse ?? false,
|
||||
EnableAlarmProjection = dto.EnableAlarmProjection ?? false,
|
||||
AlarmPollInterval = TimeSpan.FromMilliseconds(dto.AlarmPollIntervalMs ?? 1_000),
|
||||
@@ -103,19 +130,25 @@ public static class AbCipDriverFactoryExtensions
|
||||
private static TimeSpan PositiveTimeoutOrDefault(int? ms, int defaultMs) =>
|
||||
TimeSpan.FromMilliseconds(ms is int v and > 0 ? v : defaultMs);
|
||||
|
||||
// tagName null = a driver-level field: the error text names the driver config, not a tag.
|
||||
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field,
|
||||
T? fallback = null) where T : struct, Enum
|
||||
{
|
||||
var subject = tagName is null ? $"config in '{driverInstanceId}'" : $"tag '{tagName}'";
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
if (fallback.HasValue) return fallback.Value;
|
||||
throw new InvalidOperationException(
|
||||
$"AB CIP tag '{tagName ?? "<unnamed>"}' in '{driverInstanceId}' missing {field}");
|
||||
tagName is null
|
||||
? $"AB CIP {subject} missing {field}"
|
||||
: $"AB CIP {subject} in '{driverInstanceId}' missing {field}");
|
||||
}
|
||||
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
|
||||
// Enum.TryParse also accepts any integer string ("7"), defined or not; an undefined value would
|
||||
// otherwise fall through to a switch's default arm silently (Task 22 review r1 M2).
|
||||
return Enum.TryParse<T>(raw, ignoreCase: true, out var v) && Enum.IsDefined(v)
|
||||
? v
|
||||
: throw new InvalidOperationException(
|
||||
$"AB CIP tag '{tagName}' has unknown {field} '{raw}'. " +
|
||||
$"AB CIP {subject} has unknown {field} '{raw}'. " +
|
||||
$"Expected one of {string.Join(", ", Enum.GetNames<T>())}");
|
||||
}
|
||||
|
||||
@@ -128,6 +161,11 @@ public static class AbCipDriverFactoryExtensions
|
||||
|
||||
internal sealed class AbCipDriverConfigDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the transport (<c>Libplctag</c> or <c>Native</c>, case-insensitive); absent means the node default.
|
||||
/// </summary>
|
||||
public string? Transport { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timeout in milliseconds for operations.
|
||||
/// </summary>
|
||||
|
||||
@@ -2,7 +2,7 @@ using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using libplctag;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||
|
||||
@@ -11,26 +11,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// <summary>
|
||||
/// Two-phase Test-Connect probe for the <see cref="AbCipDriverOptions"/>-shaped driver config.
|
||||
/// Phase 1: bare TCP connect to the first device's gateway host + EtherNet/IP port to
|
||||
/// quickly reject unreachable targets. Phase 2: initialises a libplctag <see cref="Tag"/>
|
||||
/// quickly reject unreachable targets. Phase 2: initialises a tag runtime
|
||||
/// against the same device to open a real EIP session and perform a CIP Forward Open —
|
||||
/// confirming the remote endpoint actually speaks EtherNet/IP CIP, not just accepts TCP.
|
||||
/// A device that accepts the TCP connection but is not a CIP controller (wrong CIP path,
|
||||
/// non-CIP server) returns <c>Ok = false</c> with a "handshake failed" message instead of
|
||||
/// a false-positive green tick.
|
||||
/// <para>
|
||||
/// Status-code discrimination: libplctag statuses that indicate the controller answered
|
||||
/// the CIP session but could not resolve the probe tag
|
||||
/// (<see cref="Status.ErrorNotFound"/>, <see cref="Status.ErrorNoMatch"/>,
|
||||
/// <see cref="Status.ErrorBadDevice"/>) are treated as "reachable — Ok = true" because
|
||||
/// a non-existent tag name proves CIP connectivity. Statuses that indicate a transport or
|
||||
/// session failure (<see cref="Status.ErrorTimeout"/>, <see cref="Status.ErrorBadConnection"/>,
|
||||
/// <see cref="Status.ErrorBadGateway"/>, <see cref="Status.ErrorWinsock"/>,
|
||||
/// <see cref="Status.ErrorOpen"/>, <see cref="Status.ErrorClose"/>,
|
||||
/// <see cref="Status.ErrorRead"/>, <see cref="Status.ErrorWrite"/>,
|
||||
/// <see cref="Status.ErrorBadReply"/>, <see cref="Status.ErrorRemoteErr"/>,
|
||||
/// <see cref="Status.ErrorPartial"/>, <see cref="Status.ErrorAbort"/>) are treated as
|
||||
/// "handshake failed — Ok = false". All other error statuses default to
|
||||
/// "handshake failed" (conservative).
|
||||
/// Status-code discrimination: <c>AbStatus.Ok</c> and <c>AbStatus.NotFound</c> mean the controller
|
||||
/// answered the CIP session (a non-existent probe tag name still proves CIP connectivity) and are
|
||||
/// treated as "reachable - Ok = true". Every other status (timeout, bad connection, bad reply,
|
||||
/// partial, abort, ...) is a transport or session failure: "handshake failed - Ok = false".
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AbCipDriverProbe : IDriverProbe
|
||||
@@ -42,6 +33,24 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
private readonly IAbCipTagFactory? _tagFactory;
|
||||
private readonly AbCipTransport _defaultTransport;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="AbCipDriverProbe"/> class.</summary>
|
||||
/// <param name="tagFactory">Factory for the probe's tag runtime; when null the config's
|
||||
/// <c>Transport</c> selects the native or libplctag-backed one.</param>
|
||||
/// <param name="defaultTransport">The node-level default for a config with no <c>transport</c> key — the
|
||||
/// SAME value the node's driver factory uses, so Test Connect probes the stack the driver would run.</param>
|
||||
public AbCipDriverProbe(IAbCipTagFactory? tagFactory = null,
|
||||
AbCipTransport defaultTransport = AbCipTransportResolver.Default)
|
||||
{
|
||||
_tagFactory = tagFactory;
|
||||
_defaultTransport = defaultTransport;
|
||||
}
|
||||
|
||||
/// <summary>The node-level default this probe resolves an absent key to (wiring tests).</summary>
|
||||
public AbCipTransport DefaultTransport => _defaultTransport;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "AbCip";
|
||||
|
||||
@@ -122,9 +131,10 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
TagName: tagName,
|
||||
Timeout: timeout,
|
||||
AllowPacking: firstDevice?.AllowPacking ?? profile.SupportsRequestPacking,
|
||||
ConnectionSize: firstDevice?.ConnectionSize ?? profile.DefaultConnectionSize);
|
||||
ConnectionSize: firstDevice?.ConnectionSize ?? profile.DefaultConnectionSize,
|
||||
UseConnectedMessaging: profile.SupportsConnectedMessaging);
|
||||
|
||||
var rt = new LibplctagTagRuntime(p);
|
||||
var rt = (_tagFactory ?? AbCipTransportFactories.For(AbCipTransportResolver.Resolve(opts, _defaultTransport)).Tags).Create(p);
|
||||
try
|
||||
{
|
||||
await rt.InitializeAsync(cts.Token);
|
||||
@@ -132,10 +142,10 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
|
||||
// InitializeAsync completed without throwing — either the tag was found (Ok) or
|
||||
// libplctag returned a non-exception status. Check via GetStatus().
|
||||
var statusCode = (Status)rt.GetStatus();
|
||||
var statusCode = (AbStatus)rt.GetStatus();
|
||||
if (IsReachableStatus(statusCode))
|
||||
{
|
||||
var msg = statusCode == Status.Ok
|
||||
var msg = statusCode == AbStatus.Ok
|
||||
? "CIP session OK"
|
||||
: "CIP session OK (controller reachable; probe tag not found)";
|
||||
return new(true, msg, sw.Elapsed);
|
||||
@@ -148,9 +158,9 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
{
|
||||
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
|
||||
}
|
||||
catch (LibPlcTagException ex)
|
||||
catch (AbTransportException ex)
|
||||
{
|
||||
// LibPlcTagException carries the Status; classify as reachable vs transport failure.
|
||||
// AbTransportException carries the Status; classify as reachable vs transport failure.
|
||||
if (IsReachableException(ex))
|
||||
{
|
||||
sw.Stop();
|
||||
@@ -170,42 +180,23 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> for libplctag statuses that indicate the CIP controller answered
|
||||
/// Returns <c>true</c> for transport statuses that indicate the CIP controller answered
|
||||
/// but the probe tag name was not found or did not match — sufficient to confirm CIP
|
||||
/// reachability. Returns <c>true</c> for <see cref="Status.Ok"/> (tag was found).
|
||||
/// reachability. Returns <c>true</c> for <c>Ok</c> (tag was found).
|
||||
/// </summary>
|
||||
internal static bool IsReachableStatus(Status s) => s is
|
||||
Status.Ok or
|
||||
Status.ErrorNotFound or
|
||||
Status.ErrorNoMatch or
|
||||
Status.ErrorBadDevice;
|
||||
internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether a <see cref="LibPlcTagException"/> is a "controller answered CIP but the
|
||||
/// Decides whether an <see cref="AbTransportException"/> is a "controller answered CIP but the
|
||||
/// probe tag did not resolve" condition (reachable) or a session / transport failure
|
||||
/// (unreachable), by recovering the <see cref="Status"/> the wrapper threw on and deferring
|
||||
/// to <see cref="IsReachableStatus"/> — one classification, not two that can drift.
|
||||
/// (unreachable), deferring to <see cref="IsReachableStatus"/> — one classification, not two.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception libplctag threw out of <c>InitializeAsync</c>.</param>
|
||||
/// <param name="ex">The exception thrown out of <c>InitializeAsync</c>.</param>
|
||||
/// <returns><c>true</c> when the controller answered; <c>false</c> for a transport/session failure.</returns>
|
||||
/// <remarks>
|
||||
/// <b>The status arrives as the managed enum NAME.</b> <c>NativeTagWrapper.ThrowIfStatusNotOk</c>
|
||||
/// constructs the exception with <c>Status.ToString()</c>, so the message is exactly
|
||||
/// <c>"ErrorNotFound"</c> — never the native C spelling <c>"STATUS_NOT_FOUND"</c> this method
|
||||
/// used to look for. Those substrings could not match, so the reachable classification was
|
||||
/// unreachable on this path — and this IS the path a probe takes whenever its tag does not
|
||||
/// resolve, which is every Test Connect against a device with no authored tags. The native
|
||||
/// spellings are kept as a secondary match in case a future wrapper reports them.
|
||||
/// </remarks>
|
||||
internal static bool IsReachableException(LibPlcTagException ex)
|
||||
internal static bool IsReachableException(AbTransportException ex)
|
||||
{
|
||||
var msg = ex.Message ?? string.Empty;
|
||||
if (AbCipStatusMapper.TryGetStatus(ex, out var status))
|
||||
return IsReachableStatus(status);
|
||||
|
||||
return msg.Contains("NOT_FOUND", StringComparison.OrdinalIgnoreCase)
|
||||
|| msg.Contains("NO_MATCH", StringComparison.OrdinalIgnoreCase)
|
||||
|| msg.Contains("BAD_DEVICE", StringComparison.OrdinalIgnoreCase);
|
||||
ArgumentNullException.ThrowIfNull(ex);
|
||||
return IsReachableStatus(ex.Status);
|
||||
}
|
||||
|
||||
private static (AbCipHostAddress? parsed, AbCipDeviceOptions? device) ExtractTarget(AbCipDriverOptions opts)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using libplctag;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
@@ -26,8 +26,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// writes during download / test-mode transitions).</item>
|
||||
/// <item>0x16 object does not exist — <c>BadNodeIdUnknown</c>.</item>
|
||||
/// <item>0x1E embedded service error — unwrap to the extended status when possible.</item>
|
||||
/// <item>libplctag.NET <see cref="Status"/> errors — mapped per-member by
|
||||
/// <see cref="MapLibplctagStatus(Status)"/>: timeout, not-found, not-allowed, and
|
||||
/// <item>Transport <see cref="AbStatus"/> errors — mapped per-member by
|
||||
/// <see cref="MapStatus(AbStatus)"/>: timeout, not-found, not-allowed, and
|
||||
/// out-of-bounds get their specific OPC UA codes; the remaining transport errors
|
||||
/// fold into <c>BadCommunicationError</c>.</item>
|
||||
/// </list>
|
||||
@@ -64,66 +64,42 @@ public static class AbCipStatusMapper
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here
|
||||
/// is <c>(int)Tag.GetStatus()</c> — i.e. the underlying value of the libplctag.NET
|
||||
/// <see cref="Status"/> enum, NOT a raw native <c>PLCTAG_ERR_*</c> constant. The wrapper
|
||||
/// renumbers the native codes into a contiguous enum, so this method switches on the
|
||||
/// <see cref="Status"/> members directly to stay correct if the wrapper renumbers again.
|
||||
/// <see cref="Status.Ok"/> is success; <see cref="Status.Pending"/> is an in-flight
|
||||
/// operation; every other (negative) member is an error.
|
||||
/// Map a transport status code to an OPC UA StatusCode. The integer is
|
||||
/// <see cref="IAbCipTagRuntime.GetStatus"/>, i.e. the underlying value of <see cref="AbStatus"/>.
|
||||
/// </summary>
|
||||
/// <param name="status">The libplctag status code as an integer.</param>
|
||||
/// <param name="status">The <see cref="AbStatus"/> as an integer.</param>
|
||||
/// <returns>The mapped OPC UA StatusCode value.</returns>
|
||||
public static uint MapLibplctagStatus(int status) => MapLibplctagStatus((Status)status);
|
||||
public static uint MapStatus(int status) => MapStatus((AbStatus)status);
|
||||
|
||||
/// <summary>
|
||||
/// Recover the libplctag <see cref="Status"/> a <see cref="LibPlcTagException"/> was thrown on.
|
||||
/// The wrapper builds the exception from <c>Status.ToString()</c>, so the message is the enum
|
||||
/// name (<c>"ErrorNotFound"</c>).
|
||||
/// Map an <see cref="AbStatus"/> to an OPC UA StatusCode. Timeout, not-found, not-allowed and
|
||||
/// out-of-bounds get their specific codes; the remaining transport errors fold into
|
||||
/// <see cref="BadCommunicationError"/>.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception.</param>
|
||||
/// <param name="status">The recovered status.</param>
|
||||
/// <returns><c>true</c> when the message names a defined <see cref="Status"/>.</returns>
|
||||
public static bool TryGetStatus(LibPlcTagException ex, out Status status)
|
||||
/// <param name="status">The transport status.</param>
|
||||
/// <returns>The mapped OPC UA StatusCode value.</returns>
|
||||
public static uint MapStatus(AbStatus status) => status switch
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ex);
|
||||
return Enum.TryParse((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out status)
|
||||
&& Enum.IsDefined(status);
|
||||
}
|
||||
AbStatus.Ok => Good,
|
||||
AbStatus.Pending => GoodMoreData,
|
||||
AbStatus.Timeout => BadTimeout,
|
||||
AbStatus.NotFound => BadNodeIdUnknown,
|
||||
AbStatus.NotAllowed => BadNotWritable,
|
||||
AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall => BadOutOfRange,
|
||||
AbStatus.Unsupported => BadNotSupported,
|
||||
AbStatus.TypeMismatch => BadTypeMismatch,
|
||||
AbStatus.DeviceStateConflict => BadDeviceFailure,
|
||||
_ => BadCommunicationError, // BadConnection, BadReply, Partial, Abort
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Status for an exception thrown while creating / reading a tag: a libplctag exception maps
|
||||
/// through its <see cref="Status"/> (so a tag the controller does not have reads
|
||||
/// Status for an exception thrown while creating / reading a tag: a transport exception maps
|
||||
/// through its <see cref="AbStatus"/> (so a tag the controller does not have reads
|
||||
/// <see cref="BadNodeIdUnknown"/>, not a comms error); anything else is
|
||||
/// <see cref="BadCommunicationError"/>.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception.</param>
|
||||
/// <returns>The OPC UA status code.</returns>
|
||||
public static uint MapException(Exception ex) =>
|
||||
ex is LibPlcTagException lpe && TryGetStatus(lpe, out var status)
|
||||
? MapLibplctagStatus(status)
|
||||
: BadCommunicationError;
|
||||
|
||||
/// <summary>
|
||||
/// Map a libplctag.NET <see cref="Status"/> enum value to an OPC UA StatusCode. This is
|
||||
/// the strongly-typed core of the mapper; the <c>int</c> overload exists only for the
|
||||
/// <see cref="IAbCipTagRuntime.GetStatus"/> seam, which returns the boxed-as-int value.
|
||||
/// </summary>
|
||||
/// <param name="status">The libplctag Status enum value.</param>
|
||||
/// <returns>The mapped OPC UA StatusCode value.</returns>
|
||||
public static uint MapLibplctagStatus(Status status) => status switch
|
||||
{
|
||||
Status.Ok => Good,
|
||||
Status.Pending => GoodMoreData,
|
||||
Status.ErrorTimeout => BadTimeout,
|
||||
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => BadNodeIdUnknown,
|
||||
Status.ErrorNotAllowed => BadNotWritable,
|
||||
Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => BadOutOfRange,
|
||||
Status.ErrorUnsupported or Status.ErrorNotImplemented => BadNotSupported,
|
||||
Status.ErrorBadConnection or Status.ErrorBadGateway or Status.ErrorBadReply
|
||||
or Status.ErrorWinsock or Status.ErrorOpen or Status.ErrorClose
|
||||
or Status.ErrorRead or Status.ErrorWrite or Status.ErrorRemoteErr
|
||||
or Status.ErrorPartial or Status.ErrorAbort => BadCommunicationError,
|
||||
_ => BadCommunicationError,
|
||||
};
|
||||
ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>Maps an <see cref="AbCipTransport"/> to the matching trio of runtime factories.</summary>
|
||||
internal static class AbCipTransportFactories
|
||||
{
|
||||
/// <summary>Returns the tag, enumerator and template-reader factories for <paramref name="transport"/>.</summary>
|
||||
public static (IAbCipTagFactory Tags, IAbCipTagEnumeratorFactory Enumerators, IAbCipTemplateReaderFactory Templates)
|
||||
For(AbCipTransport transport) => transport switch
|
||||
{
|
||||
AbCipTransport.Native => (new NativeAbCipTagFactory(), new NativeAbCipTagEnumeratorFactory(),
|
||||
new NativeAbCipTemplateReaderFactory()),
|
||||
_ => (new LibplctagTagFactory(), new LibplctagTagEnumeratorFactory(), new LibplctagTemplateReaderFactory()),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// The ONE place an AbCip driver's effective transport is decided from its configured
|
||||
/// <see cref="AbCipDriverOptions.Transport"/>. The driver constructor, the Test Connect probe and the
|
||||
/// CLI (through the driver) all resolve through here, so they cannot disagree about what an absent
|
||||
/// <c>transport</c> key means.
|
||||
/// <para>Precedence (final review I1): an explicit per-driver <c>transport</c> key wins; otherwise the
|
||||
/// node-level default (<c>Drivers:AbCip:DefaultTransport</c>, read in the Host composition root and passed
|
||||
/// in as <c>nodeDefault</c>); otherwise the compiled <see cref="Default"/>.</para>
|
||||
/// </summary>
|
||||
public static class AbCipTransportResolver
|
||||
{
|
||||
/// <summary>The compiled default: the transport an absent key selects when no node default is configured.</summary>
|
||||
public const AbCipTransport Default = AbCipTransport.Native;
|
||||
|
||||
/// <summary>Returns the effective transport: an explicit value always wins; null means <paramref name="nodeDefault"/>.</summary>
|
||||
/// <param name="configured">The configured transport, or null for "use the default".</param>
|
||||
/// <param name="nodeDefault">The node-level default for drivers with no key (the compiled <see cref="Default"/> unless pinned).</param>
|
||||
/// <returns>The transport the driver runs on.</returns>
|
||||
public static AbCipTransport Resolve(AbCipTransport? configured, AbCipTransport nodeDefault = Default) =>
|
||||
configured ?? nodeDefault;
|
||||
|
||||
/// <summary>Returns the effective transport for <paramref name="options"/>.</summary>
|
||||
/// <param name="options">The driver options.</param>
|
||||
/// <param name="nodeDefault">The node-level default for drivers with no key.</param>
|
||||
/// <returns>The transport the driver runs on.</returns>
|
||||
public static AbCipTransport Resolve(AbCipDriverOptions options, AbCipTransport nodeDefault = Default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return Resolve(options.Transport, nodeDefault);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a transport name the way the driver-config <c>transport</c> key and the node-level default
|
||||
/// are parsed: a defined enum NAME, case-insensitive. Numeric strings ("0", "7") and the flags form
|
||||
/// ("Native,Libplctag") are rejected — <c>Enum.TryParse</c> alone accepts both.
|
||||
/// </summary>
|
||||
/// <param name="raw">The raw value.</param>
|
||||
/// <param name="transport">The parsed transport.</param>
|
||||
/// <returns>True when <paramref name="raw"/> names a transport.</returns>
|
||||
public static bool TryParse(string? raw, out AbCipTransport transport)
|
||||
{
|
||||
transport = Default;
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
// Exact name match only: Enum.TryParse would also accept "1" and the flags form "Native,Libplctag".
|
||||
var name = Enum.GetNames<AbCipTransport>()
|
||||
.FirstOrDefault(n => string.Equals(n, raw.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
return name is not null && Enum.TryParse(name, out transport);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operator-facing description of the configured-vs-effective transport for the init log line:
|
||||
/// <c>Native (default)</c> when the key was absent and the compiled default applies,
|
||||
/// <c>Libplctag (node default)</c> when the key was absent and the node pinned a different default,
|
||||
/// <c>Native (configured)</c> when the key was explicit.
|
||||
/// </summary>
|
||||
/// <param name="configured">The configured transport, or null.</param>
|
||||
/// <param name="nodeDefault">The node-level default.</param>
|
||||
/// <returns>A short description.</returns>
|
||||
public static string Describe(AbCipTransport? configured, AbCipTransport nodeDefault = Default) =>
|
||||
configured is not null ? $"{configured} (configured)"
|
||||
: nodeDefault == Default ? $"{nodeDefault} (default)"
|
||||
: $"{nodeDefault} (node default)";
|
||||
}
|
||||
@@ -25,10 +25,10 @@ public interface IAbCipTagRuntime : IDisposable
|
||||
Task WriteAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Raw libplctag status code — mapped to an OPC UA StatusCode via
|
||||
/// <see cref="AbCipStatusMapper.MapLibplctagStatus(int)"/>. Zero on success, negative on error.
|
||||
/// Raw transport status code (an <c>AbStatus</c> value) — mapped to an OPC UA StatusCode via
|
||||
/// <see cref="AbCipStatusMapper.MapStatus(int)"/>. Zero on success, negative on error.
|
||||
/// </summary>
|
||||
/// <returns>The raw libplctag status code (zero on success, negative on error).</returns>
|
||||
/// <returns>The <c>AbStatus</c> value (zero on success, negative on error).</returns>
|
||||
int GetStatus();
|
||||
|
||||
/// <summary>
|
||||
@@ -119,6 +119,13 @@ public interface IAbCipTagFactory
|
||||
/// element count for an <c>isArray</c> tag — including a 1-element array.</param>
|
||||
/// <param name="IsArray">Review I-1 — the EXPLICIT array signal threaded from the tag definition so
|
||||
/// a 1-element array (<see cref="ElementCount"/> 1) is still read as an array, not a scalar.</param>
|
||||
/// <param name="AuthoredType">The tag definition's authored data type, when known. The native runtime
|
||||
/// checks the CIP type code the controller returns against it (a REAL authored as DINT is
|
||||
/// <c>TypeMismatch</c>); libplctag ignores it. Null (enumeration, probes, BOOL-in-DINT parents)
|
||||
/// skips the check.</param>
|
||||
/// <param name="UseConnectedMessaging">From <c>AbCipPlcFamilyProfile.SupportsConnectedMessaging</c> at
|
||||
/// every construction site, so the family profile stays the single authority for the native
|
||||
/// transport's routing choice. libplctag ignores it.</param>
|
||||
public sealed record AbCipTagCreateParams(
|
||||
string Gateway,
|
||||
int Port,
|
||||
@@ -129,7 +136,9 @@ public sealed record AbCipTagCreateParams(
|
||||
bool AllowPacking = true,
|
||||
int ConnectionSize = 4002,
|
||||
int ElementCount = 1,
|
||||
bool IsArray = false)
|
||||
bool IsArray = false,
|
||||
AbCipDataType? AuthoredType = null,
|
||||
bool UseConnectedMessaging = true)
|
||||
{
|
||||
/// <summary>
|
||||
/// The value for libplctag's <c>gateway</c> attribute: <c>host:port</c>, or bare <c>host</c> when
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
using libplctag;
|
||||
using libplctag.NativeImport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Releases the native tag a FAILED libplctag initialisation leaves behind (T21 press pass 1 crash).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>libplctag.NET 1.5.2's <c>NativeTagWrapper.InitializeAsync</c> calls <c>plc_tag_create_ex</c>, which returns a
|
||||
/// native handle with the wrapper's <c>callback_func_ex</c> delegate registered, and only then waits for the
|
||||
/// <c>Created</c> event. When that event carries an error (a tag the controller does not have, a refused path, ...)
|
||||
/// it throws WITHOUT setting <c>_isInitialized</c>, and <c>Dispose()</c> unregisters and destroys only
|
||||
/// <c>if (_isInitialized)</c>. So the native tag outlives its wrapper with the callback still registered; once the
|
||||
/// wrapper is collected the delegate's thunk is freed, and the next event libplctag raises on that tag — at the
|
||||
/// latest the <c>DESTROYED</c> event from <c>plc_tag_shutdown</c>, which the native library runs from
|
||||
/// <c>atexit</c> — kills the process with "A callback was made on a garbage collected delegate of type
|
||||
/// 'libplctag.NativeImport!libplctag.NativeImport.plctag+callback_func_ex::Invoke'". A timed-out initialisation
|
||||
/// unregisters the callback but never destroys the handle either, so that native tag keeps reconnecting in the
|
||||
/// background for the life of the process.</para>
|
||||
/// <para>The wrapper exposes no handle, so it is read by reflection from the two private fields of the pinned
|
||||
/// 1.5.2 wrapper (<c>Tag._tag</c>, <c>NativeTagWrapper.nativeTagHandle</c>). If a future wrapper renames them the
|
||||
/// release cannot run; the tag is then rooted for the life of the process instead, which keeps its delegate
|
||||
/// alive. That is no crash but an UNBOUNDED leak — one native tag (holding its libplctag session open) per failed
|
||||
/// create, and the data path re-creates a NotFound tag on every read — so <c>LibplctagOrphanReleaseTests</c> fails, so the bump is noticed.</para>
|
||||
/// </remarks>
|
||||
internal static class LibplctagOrphanRelease
|
||||
{
|
||||
private const BindingFlags Private = BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
private static readonly FieldInfo? WrapperField = typeof(Tag).GetField("_tag", Private);
|
||||
// Final review M2: the field must also still be an int. A wrapper that retyped it (IntPtr, long) would make
|
||||
// the cast in HandleOf throw; treating it as unreadable instead degrades to the rooting fallback ("keep
|
||||
// alive"), which the pin test reports.
|
||||
private static readonly FieldInfo? HandleField =
|
||||
WrapperField?.FieldType.GetField("nativeTagHandle", Private) is { } f && f.FieldType == typeof(int) ? f : null;
|
||||
|
||||
/// <summary>Tags whose handle could not be read: kept reachable so their callback delegate is never collected.</summary>
|
||||
private static readonly ConcurrentBag<Tag> Rooted = [];
|
||||
|
||||
private static int _released;
|
||||
|
||||
/// <summary>True when the pinned wrapper's private fields were found, so a failed tag can be destroyed.</summary>
|
||||
internal static bool CanReadHandles => HandleField is not null && !ForceRootingForTest;
|
||||
|
||||
/// <summary>Test seam (review r1 M1): take the rooting fallback as if the wrapper's fields had been renamed.</summary>
|
||||
internal static bool ForceRootingForTest { get; set; }
|
||||
|
||||
/// <summary>Orphaned native tags destroyed so far (process-wide; diagnostics and tests).</summary>
|
||||
internal static int Released => Volatile.Read(ref _released);
|
||||
|
||||
/// <summary>Orphaned tags rooted for the process lifetime because their handle could not be read.</summary>
|
||||
internal static int RootedCount => Rooted.Count;
|
||||
|
||||
/// <summary>The native handle the wrapper holds (0 when none was ever created, or unreadable). Never throws:
|
||||
/// callers run it inside a catch block, where an exception would replace the failure being handled.</summary>
|
||||
internal static int HandleOf(Tag tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
return HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper
|
||||
&& HandleField.GetValue(wrapper) is int handle ? handle : 0;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears the wrapper's handle after a destroy, so a repeat release is a no-op (review r1 M2).</summary>
|
||||
private static void ClearHandle(Tag tag)
|
||||
{
|
||||
if (HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper) HandleField.SetValue(wrapper, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a libplctag operation that may initialise <paramref name="tag"/> (InitializeAsync, or a first
|
||||
/// Read/Write, which initialise on demand). If it fails and leaves the tag uninitialised, the native tag it
|
||||
/// created is unregistered and destroyed before the failure propagates.
|
||||
/// </summary>
|
||||
internal static async Task RunAsync(Tag tag, Func<Task> op)
|
||||
{
|
||||
try
|
||||
{
|
||||
await op().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
ReleaseIfOrphaned(tag);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="RunAsync(Tag, Func{Task})"/>
|
||||
internal static async Task<T> RunAsync<T>(Tag tag, Func<Task<T>> op)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await op().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
ReleaseIfOrphaned(tag);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Destroys the native tag behind an UNINITIALISED wrapper, unregistering its callback first. No-op for an
|
||||
/// initialised tag (its own <c>Dispose</c> handles it) or one that never got a handle. Never throws.
|
||||
/// </summary>
|
||||
/// <returns>The status the native tag held just before it was destroyed (what <c>GetStatus()</c> would have
|
||||
/// reported), or null when nothing was destroyed.</returns>
|
||||
internal static Status? ReleaseIfOrphaned(Tag tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (tag.IsInitialized) return null;
|
||||
if (!CanReadHandles)
|
||||
{
|
||||
Rooted.Add(tag);
|
||||
return null;
|
||||
}
|
||||
var handle = HandleOf(tag);
|
||||
if (handle <= 0) return null; // plc_tag_create_ex refused synchronously: nothing native exists
|
||||
var status = (Status)plctag.plc_tag_status(handle);
|
||||
plctag.plc_tag_unregister_callback(handle);
|
||||
plctag.plc_tag_destroy(handle);
|
||||
ClearHandle(tag);
|
||||
Interlocked.Increment(ref _released);
|
||||
return status;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A release must never mask the initialisation failure it follows. Root the tag so that, whatever
|
||||
// state the native side is in, its callback delegate stays alive.
|
||||
Rooted.Add(tag);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a parsed CIP route for libplctag's <c>Path</c> attribute. libplctag rejects an
|
||||
/// empty-string path with <c>ErrorBadData</c>; "no route" (Micro800, unrouted MicroLogix) must be
|
||||
/// expressed by omitting the attribute, i.e. <see langword="null"/>.
|
||||
/// </summary>
|
||||
internal static class LibplctagPath
|
||||
{
|
||||
/// <summary>Returns <see langword="null"/> for a null, empty or whitespace path; otherwise the path unchanged.</summary>
|
||||
/// <param name="path">The parsed CIP path.</param>
|
||||
public static string? Normalize(string? path) => string.IsNullOrWhiteSpace(path) ? null : path;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using libplctag;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>The ONLY place libplctag's <see cref="Status"/> meets <see cref="AbStatus"/>.</summary>
|
||||
internal static class LibplctagStatusTranslation
|
||||
{
|
||||
/// <summary>Translates a libplctag status into the driver-owned <see cref="AbStatus"/>.</summary>
|
||||
/// <param name="s">The libplctag status.</param>
|
||||
/// <returns>The equivalent transport-neutral status.</returns>
|
||||
public static AbStatus ToAbStatus(Status s) => s switch
|
||||
{
|
||||
Status.Ok => AbStatus.Ok,
|
||||
Status.Pending => AbStatus.Pending,
|
||||
Status.ErrorTimeout => AbStatus.Timeout,
|
||||
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.NotFound,
|
||||
Status.ErrorNotAllowed => AbStatus.NotAllowed,
|
||||
Status.ErrorOutOfBounds => AbStatus.OutOfBounds,
|
||||
Status.ErrorTooLarge => AbStatus.TooLarge,
|
||||
Status.ErrorTooSmall => AbStatus.TooSmall,
|
||||
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbStatus.Unsupported,
|
||||
Status.ErrorPartial => AbStatus.Partial,
|
||||
Status.ErrorAbort => AbStatus.Abort,
|
||||
Status.ErrorBadReply => AbStatus.BadReply,
|
||||
_ => AbStatus.BadConnection,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Recovers the status a <see cref="LibPlcTagException"/> was thrown on. libplctag builds the
|
||||
/// exception from <c>Status.ToString()</c>, so the message is the enum name.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception.</param>
|
||||
/// <returns>The translated status, or <see cref="AbStatus.BadConnection"/> when the message names no status.</returns>
|
||||
public static AbStatus ToAbStatus(LibPlcTagException ex) =>
|
||||
Enum.TryParse<Status>((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out var s) && Enum.IsDefined(s)
|
||||
? ToAbStatus(s)
|
||||
: AbStatus.BadConnection;
|
||||
|
||||
/// <summary>Runs <paramref name="op"/>, rethrowing any <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
|
||||
/// <param name="op">The libplctag operation.</param>
|
||||
/// <returns>A task completing when the operation does.</returns>
|
||||
public static async Task Wrap(Func<Task> op)
|
||||
{
|
||||
try { await op().ConfigureAwait(false); }
|
||||
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
|
||||
}
|
||||
|
||||
/// <summary>Runs <paramref name="op"/>, rethrowing any <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
|
||||
/// <typeparam name="T">The result type.</typeparam>
|
||||
/// <param name="op">The libplctag operation.</param>
|
||||
/// <returns>The operation result.</returns>
|
||||
public static async Task<T> Wrap<T>(Func<Task<T>> op)
|
||||
{
|
||||
try { return await op().ConfigureAwait(false); }
|
||||
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
|
||||
}
|
||||
|
||||
/// <summary>Runs a synchronous libplctag call, rethrowing <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
|
||||
/// <typeparam name="T">The result type.</typeparam>
|
||||
/// <param name="op">The libplctag call.</param>
|
||||
/// <returns>The call result.</returns>
|
||||
public static T WrapSync<T>(Func<T> op)
|
||||
{
|
||||
try { return op(); }
|
||||
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
|
||||
}
|
||||
|
||||
/// <summary>Runs a synchronous libplctag call, rethrowing <see cref="LibPlcTagException"/> as <see cref="AbTransportException"/>.</summary>
|
||||
/// <param name="op">The libplctag call.</param>
|
||||
public static void WrapSync(Action op)
|
||||
{
|
||||
try { op(); }
|
||||
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
|
||||
}
|
||||
}
|
||||
@@ -1,85 +1,24 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using libplctag;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Real <see cref="IAbCipTagEnumerator"/> that walks a Logix controller's symbol table by
|
||||
/// reading the <c>@tags</c> pseudo-tag via libplctag + decoding the CIP Symbol Object
|
||||
/// response with <see cref="CipSymbolObjectDecoder"/>.
|
||||
/// libplctag listing reader: reads a <c>@tags</c> / <c>Program:X.@tags</c> pseudo-tag and returns
|
||||
/// its raw Symbol Object bytes for <see cref="SymbolListingEnumerator"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>libplctag's <c>Tag.GetBuffer()</c> returns the raw Symbol Object bytes when the
|
||||
/// tag name is <c>@tags</c>. The decoder walks the concatenated entries + emits
|
||||
/// <see cref="AbCipDiscoveredTag"/> records matching our driver surface.</para>
|
||||
///
|
||||
/// <para><see cref="EmptyAbCipTagEnumerator"/> remains available for tests that don't want
|
||||
/// to touch the native library; the production factory default
|
||||
/// (<see cref="LibplctagTagEnumeratorFactory"/>) wires this implementation in.</para>
|
||||
/// </remarks>
|
||||
internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
|
||||
internal static class LibplctagListingReader
|
||||
{
|
||||
private readonly Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> _readListing;
|
||||
|
||||
/// <summary>Production enumerator: reads each listing through libplctag.</summary>
|
||||
public LibplctagTagEnumerator() : this(ReadListingAsync) { }
|
||||
|
||||
/// <summary>Enumerator over an injected listing reader (unit-test seam).</summary>
|
||||
/// <param name="readListing">Reads one symbol listing (<c>@tags</c> or <c>Program:X.@tags</c>) and
|
||||
/// returns its raw Symbol Object bytes.</param>
|
||||
internal LibplctagTagEnumerator(Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> readListing)
|
||||
=> _readListing = readListing;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A controller's <c>@tags</c> listing holds controller-scope tags plus one system entry per
|
||||
/// program (<c>Program:UN01_Press</c>); the program's own tags are listed only by
|
||||
/// <c>Program:<name>.@tags</c>. So the controller listing is read first, then each program's
|
||||
/// listing, decoded with that program as the scope. A program whose listing cannot be read is
|
||||
/// skipped — browse is best-effort and one unreadable program must not cost the others.
|
||||
/// </remarks>
|
||||
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = await _readListing(deviceParams, "@tags", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// The decoder already surfaces each struct tag's CIP template instance id on
|
||||
// AbCipDiscoveredTag.TemplateInstanceId (from the lower 12 bits of the symbol type), so
|
||||
// re-yielding the decoded records as-is carries it through to DiscoverAsync's UDT fan-out.
|
||||
var programs = new List<string>();
|
||||
foreach (var tag in CipSymbolObjectDecoder.Decode(buffer))
|
||||
{
|
||||
if (CipSymbolObjectDecoder.ProgramNameOf(tag) is { } program) programs.Add(program);
|
||||
yield return tag;
|
||||
}
|
||||
|
||||
foreach (var program in programs)
|
||||
{
|
||||
byte[] programBuffer;
|
||||
try
|
||||
{
|
||||
programBuffer = await _readListing(deviceParams, $"Program:{program}.@tags", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var tag in CipSymbolObjectDecoder.Decode(programBuffer, programScope: program))
|
||||
yield return tag;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadListingAsync(
|
||||
internal static async Task<byte[]> ReadAsync(
|
||||
AbCipTagCreateParams deviceParams, string listingName, CancellationToken cancellationToken)
|
||||
{
|
||||
using var tag = BuildSymbolListTag(deviceParams, listingName);
|
||||
await tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||
await tag.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
return tag.GetBuffer();
|
||||
// A failed initialisation would otherwise leave the native tag (and its callback) behind: see LibplctagOrphanRelease.
|
||||
return await LibplctagOrphanRelease.RunAsync(tag, () => LibplctagStatusTranslation.Wrap(async () =>
|
||||
{
|
||||
await tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||
await tag.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
return tag.GetBuffer();
|
||||
})).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -99,16 +38,13 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
|
||||
internal static Tag BuildSymbolListTag(AbCipTagCreateParams deviceParams, string listingName = "@tags") => new()
|
||||
{
|
||||
Gateway = deviceParams.LibplctagGateway,
|
||||
Path = deviceParams.CipPath,
|
||||
Path = LibplctagPath.Normalize(deviceParams.CipPath),
|
||||
PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute),
|
||||
Protocol = Protocol.ab_eip,
|
||||
Name = listingName,
|
||||
Timeout = deviceParams.Timeout,
|
||||
};
|
||||
|
||||
/// <summary>Nothing to release — each listing's libplctag tag is disposed after its read.</summary>
|
||||
public void Dispose() { }
|
||||
|
||||
private static PlcType MapPlcType(string attribute) => attribute switch
|
||||
{
|
||||
"controllogix" => PlcType.ControlLogix,
|
||||
@@ -118,9 +54,9 @@ internal sealed class LibplctagTagEnumerator : IAbCipTagEnumerator
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Factory for <see cref="LibplctagTagEnumerator"/>.</summary>
|
||||
/// <summary>Factory for the libplctag-backed <see cref="SymbolListingEnumerator"/>.</summary>
|
||||
internal sealed class LibplctagTagEnumeratorFactory : IAbCipTagEnumeratorFactory
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public IAbCipTagEnumerator Create() => new LibplctagTagEnumerator();
|
||||
public IAbCipTagEnumerator Create() => new SymbolListingEnumerator(LibplctagListingReader.ReadAsync);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using libplctag;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
@@ -13,16 +14,31 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
{
|
||||
private readonly Tag _tag;
|
||||
|
||||
/// <summary>
|
||||
/// The status the native tag held when a failed initialisation destroyed it (see
|
||||
/// <see cref="LibplctagOrphanRelease"/>); null while the wrapper's own handle is authoritative.
|
||||
/// </summary>
|
||||
private Status? _releasedStatus;
|
||||
|
||||
/// <summary>The native handle a failed initialisation destroyed (0 if none); the wrapper's own field is cleared.</summary>
|
||||
private int _releasedHandle;
|
||||
|
||||
/// <summary>Initializes a new instance of the LibplctagTagRuntime class.</summary>
|
||||
/// <param name="p">The tag creation parameters.</param>
|
||||
public LibplctagTagRuntime(AbCipTagCreateParams p)
|
||||
{
|
||||
_tag = new Tag
|
||||
_tag = BuildTag(p);
|
||||
}
|
||||
|
||||
/// <summary>Builds the (uninitialized) libplctag tag for <paramref name="p"/>.</summary>
|
||||
/// <param name="p">The tag creation parameters.</param>
|
||||
internal static Tag BuildTag(AbCipTagCreateParams p) =>
|
||||
new Tag
|
||||
{
|
||||
// host:port — see AbLegacyTagCreateParams/AbCipTagCreateParams.LibplctagGateway.
|
||||
// Passing p.Gateway alone drops the authored port onto libplctag's 44818 default (#534).
|
||||
Gateway = p.LibplctagGateway,
|
||||
Path = p.CipPath,
|
||||
Path = LibplctagPath.Normalize(p.CipPath),
|
||||
PlcType = MapPlcType(p.LibplctagPlcAttribute),
|
||||
Protocol = Protocol.ab_eip,
|
||||
Name = p.TagName,
|
||||
@@ -32,28 +48,73 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
// count so libplctag pulls every element in one CIP read; scalar tags pass 1.
|
||||
ElementCount = p.ElementCount > 1 ? p.ElementCount : 1,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(CancellationToken cancellationToken) =>
|
||||
RunReleasingOrphanAsync(() => _tag.InitializeAsync(cancellationToken));
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>libplctag initialises an uninitialised tag on its first read, so this path can orphan one too.</remarks>
|
||||
public Task ReadAsync(CancellationToken cancellationToken) =>
|
||||
RunReleasingOrphanAsync(() => _tag.ReadAsync(cancellationToken));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(CancellationToken cancellationToken) =>
|
||||
RunReleasingOrphanAsync(() => _tag.WriteAsync(cancellationToken));
|
||||
|
||||
/// <summary>
|
||||
/// Runs a libplctag operation; when it fails and leaves the tag uninitialised, destroys the native tag it
|
||||
/// created before the failure propagates, so its callback can never fire into a collected delegate (T21 press
|
||||
/// pass 1 crash). The status that tag held is kept for <see cref="GetStatus"/>.
|
||||
/// </summary>
|
||||
private async Task RunReleasingOrphanAsync(Func<Task> op)
|
||||
{
|
||||
try
|
||||
{
|
||||
await LibplctagStatusTranslation.Wrap(op).ConfigureAwait(false);
|
||||
_releasedStatus = null;
|
||||
_releasedHandle = 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
var handle = LibplctagOrphanRelease.HandleOf(_tag);
|
||||
if (LibplctagOrphanRelease.ReleaseIfOrphaned(_tag) is { } status)
|
||||
{
|
||||
_releasedStatus = status;
|
||||
_releasedHandle = handle;
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
|
||||
/// <summary>The native libplctag handle this runtime's tag holds, or the one a failed initialisation destroyed
|
||||
/// (0 if none was created). Tests only.</summary>
|
||||
internal int NativeHandle => _releasedHandle != 0 ? _releasedHandle : LibplctagOrphanRelease.HandleOf(_tag);
|
||||
|
||||
/// <summary>The handle the libplctag wrapper itself still holds (0 once a release cleared it). Tests only.</summary>
|
||||
internal int WrapperHandle => LibplctagOrphanRelease.HandleOf(_tag);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken);
|
||||
public int GetStatus() =>
|
||||
(int)LibplctagStatusTranslation.ToAbStatus(_releasedStatus ?? _tag.GetStatus());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken);
|
||||
public int GetSize() => LibplctagStatusTranslation.WrapSync(() => _tag.GetSize());
|
||||
|
||||
/// <inheritdoc />
|
||||
public int GetStatus() => (int)_tag.GetStatus();
|
||||
|
||||
/// <inheritdoc />
|
||||
public int GetSize() => _tag.GetSize();
|
||||
/// <summary>
|
||||
/// A copy of the bytes of the last read, for the read-only differential harness (Task 18) to compare
|
||||
/// against the native runtime byte for byte. Not part of <see cref="IAbCipTagRuntime"/>.
|
||||
/// </summary>
|
||||
internal ReadOnlyMemory<byte> RawBuffer => LibplctagStatusTranslation.WrapSync(() => _tag.GetBuffer());
|
||||
|
||||
/// <inheritdoc />
|
||||
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
|
||||
|
||||
/// <inheritdoc />
|
||||
public object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex) => type switch
|
||||
public object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex) =>
|
||||
LibplctagStatusTranslation.WrapSync(() => DecodeValueAtCore(type, offset, bitIndex));
|
||||
|
||||
private object? DecodeValueAtCore(AbCipDataType type, int offset, int? bitIndex) => type switch
|
||||
{
|
||||
AbCipDataType.Bool => bitIndex is int bit
|
||||
? _tag.GetBit(bit)
|
||||
@@ -75,7 +136,10 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public object? DecodeArray(AbCipDataType type, int count)
|
||||
public object? DecodeArray(AbCipDataType type, int count) =>
|
||||
LibplctagStatusTranslation.WrapSync(() => DecodeArrayCore(type, count));
|
||||
|
||||
private object? DecodeArrayCore(AbCipDataType type, int count)
|
||||
{
|
||||
if (count < 1) count = 1;
|
||||
// libplctag reports the per-element byte size once the tag has been read; it correctly
|
||||
@@ -156,7 +220,10 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
|
||||
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value) =>
|
||||
LibplctagStatusTranslation.WrapSync(() => EncodeValueCore(type, bitIndex, value));
|
||||
|
||||
private void EncodeValueCore(AbCipDataType type, int? bitIndex, object? value)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
|
||||
@@ -20,9 +20,14 @@ internal sealed class LibplctagTemplateReader : IAbCipTemplateReader
|
||||
{
|
||||
_tag?.Dispose();
|
||||
_tag = BuildTemplateTag(deviceParams, templateInstanceId);
|
||||
await _tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _tag.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
return _tag.GetBuffer();
|
||||
var tag = _tag;
|
||||
// A failed initialisation would otherwise leave the native tag (and its callback) behind: see LibplctagOrphanRelease.
|
||||
return await LibplctagOrphanRelease.RunAsync(tag, () => LibplctagStatusTranslation.Wrap(async () =>
|
||||
{
|
||||
await tag.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||
await tag.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
return tag.GetBuffer();
|
||||
})).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Builds the (uninitialized) libplctag tag for the <c>@udt/{id}</c> pseudo.</summary>
|
||||
@@ -35,7 +40,7 @@ internal sealed class LibplctagTemplateReader : IAbCipTemplateReader
|
||||
internal static Tag BuildTemplateTag(AbCipTagCreateParams deviceParams, uint templateInstanceId) => new()
|
||||
{
|
||||
Gateway = deviceParams.LibplctagGateway,
|
||||
Path = deviceParams.CipPath,
|
||||
Path = LibplctagPath.Normalize(deviceParams.CipPath),
|
||||
PlcType = MapPlcType(deviceParams.LibplctagPlcAttribute),
|
||||
Protocol = Protocol.ab_eip,
|
||||
Name = $"@udt/{templateInstanceId}",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Maps <see cref="AbCipTagCreateParams"/> onto the native transport's connection identity and
|
||||
/// settings. Shared by the native tag runtime, enumerator and template reader so every handle on
|
||||
/// one device leases the same pooled connection.
|
||||
/// </summary>
|
||||
internal static class NativeAbCipDevice
|
||||
{
|
||||
/// <summary>The EtherNet/IP default TCP port, used when no usable port was configured.</summary>
|
||||
internal const int DefaultPort = 44818;
|
||||
|
||||
/// <summary>The <c>plc=</c> attribute of the Micro800 family profile.</summary>
|
||||
private const string Micro800Attribute = "micro800";
|
||||
|
||||
/// <summary>The pool key: one CIP connection per <c>(host, port, cipPath, plcAttribute)</c>.</summary>
|
||||
/// <param name="p">The tag creation parameters.</param>
|
||||
/// <returns>The device key.</returns>
|
||||
public static DeviceKey Key(AbCipTagCreateParams p) =>
|
||||
new(p.Gateway, p.Port > 0 ? p.Port : DefaultPort, p.CipPath, p.LibplctagPlcAttribute);
|
||||
|
||||
/// <summary>The connection settings for the device <paramref name="p"/> addresses.</summary>
|
||||
/// <param name="p">The tag creation parameters.</param>
|
||||
/// <returns>The connection options.</returns>
|
||||
public static ConnectionOptions Options(AbCipTagCreateParams p) => new()
|
||||
{
|
||||
Routing = RoutingFor(p),
|
||||
Timeout = p.Timeout,
|
||||
ConnectionSize = p.ConnectionSize,
|
||||
AllowPacking = p.AllowPacking,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// How requests reach the controller's message router.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Micro800 is <b>always</b> <see cref="CipRouting.Connected"/> with an empty route, even though its
|
||||
/// family profile says <c>SupportsConnectedMessaging: false</c>: libplctag opens a Large Forward
|
||||
/// Open with connection path <c>20 02 24 01</c> and reads connected (Captures/micro800-read-dint.hex,
|
||||
/// Captures/README.md "Routing observed"; ruling R12-1 — the capture beats the profile flag).
|
||||
/// Every other family follows the profile: connected-capable ⇒ Forward Open; otherwise
|
||||
/// Unconnected Send when a route is configured, bare SendRRData when it is not.
|
||||
/// </remarks>
|
||||
/// <param name="p">The tag creation parameters.</param>
|
||||
/// <returns>The routing mode.</returns>
|
||||
internal static CipRouting RoutingFor(AbCipTagCreateParams p)
|
||||
{
|
||||
if (string.Equals(p.LibplctagPlcAttribute, Micro800Attribute, StringComparison.OrdinalIgnoreCase))
|
||||
return CipRouting.Connected;
|
||||
if (p.UseConnectedMessaging) return CipRouting.Connected;
|
||||
return string.IsNullOrWhiteSpace(p.CipPath) ? CipRouting.Direct : CipRouting.UnconnectedSend;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Native <see cref="IAbCipTagEnumerator"/>: one <see cref="SymbolListingEnumerator"/> whose listing
|
||||
/// reader pages the Symbol Object Get Instance Attribute List service. The whole enumeration (the
|
||||
/// controller listing plus every program listing) runs on one leased device connection, sent through
|
||||
/// the lease's shared scheduler, and the lease is released when the enumeration ends.
|
||||
/// </summary>
|
||||
internal sealed class NativeAbCipTagEnumerator : IAbCipTagEnumerator
|
||||
{
|
||||
private readonly Func<AbCipTagCreateParams, (RequestScheduler Scheduler, Func<ValueTask> Release)> _acquire;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>Production constructor: leases from <paramref name="pool"/>.</summary>
|
||||
/// <param name="pool">The connection pool (the factory passes <see cref="DeviceSessionPool.Shared"/>).</param>
|
||||
public NativeAbCipTagEnumerator(DeviceSessionPool pool)
|
||||
: this(p =>
|
||||
{
|
||||
var lease = pool.Acquire(NativeAbCipDevice.Key(p), NativeAbCipDevice.Options(p));
|
||||
return (lease.Scheduler, lease.DisposeAsync);
|
||||
})
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam: supplies the scheduler and its release directly.</summary>
|
||||
/// <param name="acquire">Acquires the scheduler for a device, with the callback that releases it.</param>
|
||||
/// <param name="logger">Receives release failures; null discards them.</param>
|
||||
internal NativeAbCipTagEnumerator(
|
||||
Func<AbCipTagCreateParams, (RequestScheduler Scheduler, Func<ValueTask> Release)> acquire,
|
||||
ILogger? logger = null)
|
||||
{
|
||||
_acquire = acquire;
|
||||
_logger = logger ?? NullLogger.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var (scheduler, release) = _acquire(deviceParams);
|
||||
try
|
||||
{
|
||||
using var inner = new SymbolListingEnumerator(
|
||||
(_, name, ct) => NativeListingReader.ReadAsync(scheduler, name, ct));
|
||||
await foreach (var tag in inner.EnumerateAsync(deviceParams, cancellationToken).ConfigureAwait(false))
|
||||
yield return tag;
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeLease.ReleaseBounded(release, _logger);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Nothing to release — each enumeration owns and releases its own lease.</summary>
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>Pages a symbol listing off the controller into the concatenated Symbol Object entry bytes.</summary>
|
||||
internal static class NativeListingReader
|
||||
{
|
||||
private const string TagsSuffix = ".@tags";
|
||||
private const string ProgramPrefix = "Program:";
|
||||
|
||||
/// <summary>Page cap for one listing; a real controller needs a small fraction of this.</summary>
|
||||
internal const int MaxPages = 10_000;
|
||||
|
||||
/// <summary>Byte cap for one listing (64 MiB).</summary>
|
||||
internal const long MaxListingBytes = 64L * 1024 * 1024;
|
||||
|
||||
/// <summary>Reads <c>@tags</c> or <c>Program:X.@tags</c> in full.</summary>
|
||||
/// <param name="scheduler">The device's shared scheduler.</param>
|
||||
/// <param name="listingName">The listing pseudo-tag name.</param>
|
||||
/// <param name="cancellationToken">Cancels the read.</param>
|
||||
/// <returns>The raw entries, byte for byte what <see cref="CipSymbolObjectDecoder"/> expects.</returns>
|
||||
public static async Task<byte[]> ReadAsync(
|
||||
RequestScheduler scheduler, string listingName, CancellationToken cancellationToken)
|
||||
{
|
||||
string? program = null;
|
||||
if (listingName != "@tags")
|
||||
{
|
||||
if (!listingName.StartsWith(ProgramPrefix, StringComparison.Ordinal)
|
||||
|| !listingName.EndsWith(TagsSuffix, StringComparison.Ordinal))
|
||||
throw new ArgumentException($"Unsupported symbol listing '{listingName}'.", nameof(listingName));
|
||||
program = listingName[ProgramPrefix.Length..^TagsSuffix.Length];
|
||||
}
|
||||
|
||||
using var all = new MemoryStream();
|
||||
uint start = 0;
|
||||
for (var page = 0; page < MaxPages; page++)
|
||||
{
|
||||
var request = SymbolObjectServices.GetInstanceAttributeList(program, start);
|
||||
// Exclusive: the reply can fill the connection, and the packer budgets request bytes only.
|
||||
var reply = await scheduler.SendAsync(request, cancellationToken, exclusive: true).ConfigureAwait(false);
|
||||
var (entries, last, more) = SymbolObjectServices.ParseListingReply(reply);
|
||||
all.Write(entries);
|
||||
if (all.Length > MaxListingBytes)
|
||||
throw new AbTransportException(AbStatus.BadReply,
|
||||
$"Symbol listing '{listingName}' exceeded {MaxListingBytes} bytes.");
|
||||
if (!more) return all.ToArray();
|
||||
// Instances ascend; a page that does not advance past its start, or a last id that would
|
||||
// wrap, is a misbehaving reply and would otherwise loop forever against the controller.
|
||||
if (last < start || last == uint.MaxValue)
|
||||
throw new AbTransportException(AbStatus.BadReply,
|
||||
$"Symbol listing '{listingName}' made no progress (start {start}, last {last}).");
|
||||
start = last + 1;
|
||||
}
|
||||
throw new AbTransportException(AbStatus.BadReply,
|
||||
$"Symbol listing '{listingName}' exceeded {MaxPages} pages.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Factory for <see cref="NativeAbCipTagEnumerator"/>.</summary>
|
||||
internal sealed class NativeAbCipTagEnumeratorFactory : IAbCipTagEnumeratorFactory
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public IAbCipTagEnumerator Create() => new NativeAbCipTagEnumerator(DeviceSessionPool.Shared);
|
||||
}
|
||||
|
||||
/// <summary>Bounded lease release shared by the native enumerator and template reader.</summary>
|
||||
internal static class NativeLease
|
||||
{
|
||||
/// <summary>Upper bound on waiting for the lease release (and its Forward Close).</summary>
|
||||
private static readonly TimeSpan DisposeWait = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>Releases, waiting at most 2 s; never throws, whatever the release delegate does.</summary>
|
||||
public static void ReleaseBounded(Func<ValueTask> release, ILogger logger)
|
||||
{
|
||||
try { release().AsTask().Wait(DisposeWait); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Releasing the native AbCip device lease failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IAbCipTagFactory"/> for the native transport: every runtime leases its device's
|
||||
/// connection from <see cref="DeviceSessionPool.Shared"/>, so all tags on one device share one CIP
|
||||
/// connection and one request scheduler. Stateless; safe to share across devices.
|
||||
/// </summary>
|
||||
internal sealed class NativeAbCipTagFactory(DeviceSessionPool? pool = null) : IAbCipTagFactory
|
||||
{
|
||||
// Tests pass a private pool (and a fake target); production uses the process-wide one.
|
||||
private readonly DeviceSessionPool _pool = pool ?? DeviceSessionPool.Shared;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) =>
|
||||
new NativeAbCipTagRuntime(createParams, _pool);
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Native <see cref="IAbCipTagRuntime"/> over the managed EtherNet/IP + CIP stack. Leases the
|
||||
/// device's shared connection from <see cref="DeviceSessionPool.Shared"/> and reads/writes through a
|
||||
/// per-runtime <see cref="LogixTagClient"/> over the lease's <b>shared</b> scheduler, so concurrent
|
||||
/// tags on one device coalesce into Multiple Service Packets (review B1). Decoding mirrors
|
||||
/// <see cref="LibplctagTagRuntime"/> byte for byte; unlike libplctag it also checks the CIP type
|
||||
/// code the controller returned against the authored type.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not thread-safe: the driver serialises Read → GetStatus → Decode (and Encode → Write → GetStatus)
|
||||
/// per runtime under its per-runtime lock (05/STAB-4), exactly as it does for libplctag.
|
||||
/// </remarks>
|
||||
internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
{
|
||||
/// <summary>Upper bound on waiting for the lease release (and its Forward Close) in <see cref="Dispose"/>.</summary>
|
||||
private static readonly TimeSpan DisposeWait = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>Bytes of a Logix STRING's length word.</summary>
|
||||
private const int StringLengthBytes = 4;
|
||||
|
||||
private readonly AbCipTagCreateParams _p;
|
||||
private readonly DeviceSessionPool? _pool;
|
||||
private readonly ushort _elements;
|
||||
|
||||
// Encoded in InitializeAsync, never in the constructor: a malformed name must surface from
|
||||
// Initialize (inside the driver's / probe's try), not from IAbCipTagFactory.Create — as libplctag.
|
||||
private int? _implicitBit;
|
||||
private byte[]? _path;
|
||||
|
||||
private ConnectionLease? _lease;
|
||||
private RequestScheduler? _privateScheduler; // ForTest only — production always uses the lease's
|
||||
private LogixTagClient? _client;
|
||||
|
||||
private byte[] _data = [];
|
||||
private ushort? _typeCode;
|
||||
private ushort? _structHandle;
|
||||
private AbStatus _status = AbStatus.Pending;
|
||||
|
||||
private byte[]? _writeBuffer;
|
||||
private string? _pendingString;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Production constructor; <see cref="InitializeAsync"/> leases the connection from <paramref name="pool"/>.</summary>
|
||||
/// <param name="p">The tag creation parameters.</param>
|
||||
/// <param name="pool">The connection pool (the driver passes <see cref="DeviceSessionPool.Shared"/>).</param>
|
||||
internal NativeAbCipTagRuntime(AbCipTagCreateParams p, DeviceSessionPool pool)
|
||||
: this(p)
|
||||
{
|
||||
_pool = pool ?? throw new ArgumentNullException(nameof(pool));
|
||||
}
|
||||
|
||||
private NativeAbCipTagRuntime(AbCipTagCreateParams p)
|
||||
{
|
||||
_p = p ?? throw new ArgumentNullException(nameof(p));
|
||||
_elements = (ushort)Math.Clamp(p.ElementCount, 1, ushort.MaxValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: a runtime over <paramref name="channel"/> with a private scheduler and no pool
|
||||
/// lease. Production code always goes through <see cref="NativeAbCipTagFactory"/>.
|
||||
/// </summary>
|
||||
/// <param name="channel">The channel to send on.</param>
|
||||
/// <param name="p">The tag creation parameters.</param>
|
||||
/// <returns>The runtime.</returns>
|
||||
internal static NativeAbCipTagRuntime ForTest(ICipChannel channel, AbCipTagCreateParams p)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
var rt = new NativeAbCipTagRuntime(p);
|
||||
rt._privateScheduler = new RequestScheduler(channel, p.AllowPacking);
|
||||
rt._client = new LogixTagClient(rt._privateScheduler, channel);
|
||||
return rt;
|
||||
}
|
||||
|
||||
/// <summary>The CIP type code of the last successful read, if any.</summary>
|
||||
internal ushort? TypeCode => _typeCode;
|
||||
|
||||
/// <summary>The structure handle of the last successful read, if the controller returned one.</summary>
|
||||
internal ushort? StructHandle => _structHandle;
|
||||
|
||||
/// <summary>The trailing bit selector split off the tag name (<c>Tag.5</c>), if any.</summary>
|
||||
internal int? ImplicitBit => _implicitBit;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Performs one read to prove the tag exists: libplctag's <c>InitializeAsync</c> errors for an
|
||||
/// unknown tag, and the driver's 05/STAB-8 backoff classification depends on create throwing
|
||||
/// <see cref="AbStatus.NotFound"/>.
|
||||
/// </remarks>
|
||||
public async Task InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_path is null)
|
||||
{
|
||||
// Before any lease: a malformed name costs no connection.
|
||||
var (name, bit) = CipPath.SplitBitSelector(_p.TagName);
|
||||
var path = CipPath.EncodeSymbolic(name);
|
||||
_implicitBit = bit;
|
||||
_path = path;
|
||||
}
|
||||
if (_client is null)
|
||||
{
|
||||
var lease = _pool!.Acquire(NativeAbCipDevice.Key(_p), NativeAbCipDevice.Options(_p));
|
||||
_lease = lease;
|
||||
// The lease's shared scheduler — never a private one (B1: MSP coalescing across tags).
|
||||
_client = new LogixTagClient(lease.Scheduler, lease.Channel);
|
||||
}
|
||||
await ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var client = Client();
|
||||
var path = Path();
|
||||
try
|
||||
{
|
||||
var (type, handle, data) = await client.ReadAsync(path,_elements, cancellationToken).ConfigureAwait(false);
|
||||
_data = data;
|
||||
_typeCode = type;
|
||||
_structHandle = handle;
|
||||
_status = IsTypeMismatch(type) ? AbStatus.TypeMismatch : AbStatus.Ok;
|
||||
}
|
||||
catch (AbTransportException ex)
|
||||
{
|
||||
// libplctag reports a failed operation by exception AND leaves the status readable.
|
||||
_status = ex.Status;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task WriteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var client = Client();
|
||||
var path = Path();
|
||||
|
||||
// A bit tag ("Flags.3") names one bit of its host word. Writing any encoded value to it would
|
||||
// write the WHOLE host and wipe its other bits on the controller; BOOL bit writes are an RMW the
|
||||
// driver performs on a parent-DINT runtime (WriteBitInDIntAsync), so anything reaching here is refused.
|
||||
if (_implicitBit is int implicitBit)
|
||||
throw new NotSupportedException(
|
||||
$"Write to bit tag '{_p.TagName}' (bit {implicitBit}) is not supported here — bit writes go through AbCipDriver.WriteBitInDIntAsync.");
|
||||
|
||||
try
|
||||
{
|
||||
if (_pendingString is { } text)
|
||||
{
|
||||
// A STRING writes as the type the controller reported — a Logix STRING is a structure
|
||||
// (0x02A0 + handle); ab_server reports 0x00D0 with no handle. Learn it by reading first
|
||||
// when it is not yet known (we run under the driver's per-runtime lock).
|
||||
if (_typeCode is null || (_typeCode == LogixTypeCode.Struct && _structHandle is null))
|
||||
await ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (_typeCode == LogixTypeCode.Struct && _structHandle is null)
|
||||
throw new AbTransportException(AbStatus.Unsupported,
|
||||
$"STRING write to '{_p.TagName}': the controller returned no structure handle.");
|
||||
var payload = EncodeStringPayload(text);
|
||||
await client.WriteAsync(path, _typeCode!.Value, _structHandle, 1, payload, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else if (_writeBuffer is { } buffer)
|
||||
{
|
||||
var type = _typeCode ?? AtomicCode(_p.AuthoredType)
|
||||
?? throw new AbTransportException(AbStatus.Unsupported,
|
||||
$"Write to '{_p.TagName}': type unknown — no prior read and no authored atomic type.");
|
||||
// The read rule decides first: a same-width mismatch (REAL under a DINT-authored tag)
|
||||
// would otherwise send the int bit pattern as a REAL and report Good.
|
||||
if (_typeCode is { } returned && IsTypeMismatch(returned))
|
||||
throw new AbTransportException(AbStatus.TypeMismatch,
|
||||
$"Write to '{_p.TagName}': the controller's type 0x{returned:X4} does not match the authored {_p.AuthoredType}.");
|
||||
// Width is checked only for codes with a known width: DATE / DATE_AND_TIME (0xCD/0xCF) and
|
||||
// Micro800 BYTE/WORD/LWORD (0xD1/0xD2/0xD4) are outside LogixTypeCode's table and write as today.
|
||||
var width = LogixTypeCode.ByteWidth(type);
|
||||
if (width > 0 && width != buffer.Length)
|
||||
{
|
||||
// An authored BOOL over a packed BOOL-array DWORD: writing the DWORD would clobber the
|
||||
// 31 sibling bools (libplctag does exactly that), so refuse it as a config error.
|
||||
if (type == LogixTypeCode.BoolArray)
|
||||
throw new NotSupportedException(
|
||||
$"Write to '{_p.TagName}': a BOOL-array element (type 0xD3 DWORD) cannot be written as a single BOOL.");
|
||||
throw new AbTransportException(AbStatus.TypeMismatch,
|
||||
$"Write to '{_p.TagName}': encoded {buffer.Length} byte(s) but the controller's type 0x{type:X4} is {width} byte(s) wide.");
|
||||
}
|
||||
await client.WriteAsync(path, type, null, 1, buffer, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("WriteAsync called before EncodeValue.");
|
||||
}
|
||||
_status = AbStatus.Ok;
|
||||
}
|
||||
catch (AbTransportException ex)
|
||||
{
|
||||
_status = ex.Status;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int GetStatus() => (int)_status;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int GetSize() => _data.Length;
|
||||
|
||||
/// <summary>
|
||||
/// The bytes of the last read, for the read-only differential harness (Task 18) to compare against
|
||||
/// libplctag byte for byte. Not part of <see cref="IAbCipTagRuntime"/>.
|
||||
/// </summary>
|
||||
internal ReadOnlyMemory<byte> RawBuffer => _data;
|
||||
|
||||
/// <inheritdoc />
|
||||
public object? DecodeValue(AbCipDataType type, int? bitIndex) => DecodeValueAt(type, 0, bitIndex);
|
||||
|
||||
/// <inheritdoc />
|
||||
public object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex) =>
|
||||
_status == AbStatus.TypeMismatch ? null : DecodeAt(type, offset, bitIndex);
|
||||
|
||||
private object? DecodeAt(AbCipDataType type, int offset, int? bitIndex)
|
||||
{
|
||||
// libplctag on a bit tag ("Tag.5"): every integer getter returns that bit, and GetBit ignores
|
||||
// the index it is given in favour of the tag's own bit.
|
||||
if (_implicitBit is int implicitBit && IsIntegerShaped(type))
|
||||
return BoxBit(type, GetBit(implicitBit));
|
||||
|
||||
return type switch
|
||||
{
|
||||
AbCipDataType.Bool => bitIndex is int bit ? GetBit(bit) : Span(offset, 1)[0] != 0,
|
||||
AbCipDataType.SInt => (int)(sbyte)Span(offset, 1)[0],
|
||||
AbCipDataType.USInt => (int)Span(offset, 1)[0],
|
||||
AbCipDataType.Int => (int)BinaryPrimitives.ReadInt16LittleEndian(Span(offset, 2)),
|
||||
AbCipDataType.UInt => (int)BinaryPrimitives.ReadUInt16LittleEndian(Span(offset, 2)),
|
||||
AbCipDataType.DInt or AbCipDataType.Dt => BinaryPrimitives.ReadInt32LittleEndian(Span(offset, 4)),
|
||||
AbCipDataType.UDInt => BinaryPrimitives.ReadUInt32LittleEndian(Span(offset, 4)),
|
||||
AbCipDataType.LInt => BinaryPrimitives.ReadInt64LittleEndian(Span(offset, 8)),
|
||||
AbCipDataType.ULInt => BinaryPrimitives.ReadUInt64LittleEndian(Span(offset, 8)),
|
||||
AbCipDataType.Real => BinaryPrimitives.ReadSingleLittleEndian(Span(offset, 4)),
|
||||
AbCipDataType.LReal => BinaryPrimitives.ReadDoubleLittleEndian(Span(offset, 8)),
|
||||
AbCipDataType.String => DecodeString(offset),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public object? DecodeArray(AbCipDataType type, int count)
|
||||
{
|
||||
if (_status == AbStatus.TypeMismatch) return null;
|
||||
if (count < 1) count = 1;
|
||||
// Same stride rule as libplctag's runtime (which never has ElementSize after a read).
|
||||
var stride = LibplctagTagRuntime.ResolveArrayStride(null, _data.Length, count, type);
|
||||
return type switch
|
||||
{
|
||||
// A Logix BOOL array is bit-packed: element i is bit i of the buffer.
|
||||
AbCipDataType.Bool => BuildBoolArray(count),
|
||||
AbCipDataType.SInt or AbCipDataType.USInt or AbCipDataType.Int or AbCipDataType.UInt
|
||||
or AbCipDataType.DInt or AbCipDataType.Dt => BuildArray<int>(count, stride, type),
|
||||
AbCipDataType.UDInt => BuildArray<uint>(count, stride, type),
|
||||
AbCipDataType.LInt => BuildArray<long>(count, stride, type),
|
||||
AbCipDataType.ULInt => BuildArray<ulong>(count, stride, type),
|
||||
AbCipDataType.Real => BuildArray<float>(count, stride, type),
|
||||
AbCipDataType.LReal => BuildArray<double>(count, stride, type),
|
||||
AbCipDataType.String => BuildArray<string>(count, stride, type),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
|
||||
{
|
||||
_writeBuffer = null;
|
||||
_pendingString = null;
|
||||
switch (type)
|
||||
{
|
||||
case AbCipDataType.Bool:
|
||||
if (bitIndex is int)
|
||||
{
|
||||
// Same contract as LibplctagTagRuntime: BOOL-within-DINT writes are an RMW the driver
|
||||
// performs on a parent-DINT runtime (AbCipDriver.WriteBitInDIntAsync).
|
||||
throw new NotSupportedException(
|
||||
"BOOL-with-bitIndex writes must go through AbCipDriver.WriteBitInDIntAsync, not NativeAbCipTagRuntime.");
|
||||
}
|
||||
_writeBuffer = [Convert.ToBoolean(value) ? (byte)1 : (byte)0];
|
||||
break;
|
||||
case AbCipDataType.SInt:
|
||||
_writeBuffer = [(byte)Convert.ToSByte(value)];
|
||||
break;
|
||||
case AbCipDataType.USInt:
|
||||
_writeBuffer = [Convert.ToByte(value)];
|
||||
break;
|
||||
case AbCipDataType.Int:
|
||||
_writeBuffer = new byte[2];
|
||||
BinaryPrimitives.WriteInt16LittleEndian(_writeBuffer, Convert.ToInt16(value));
|
||||
break;
|
||||
case AbCipDataType.UInt:
|
||||
_writeBuffer = new byte[2];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(_writeBuffer, Convert.ToUInt16(value));
|
||||
break;
|
||||
case AbCipDataType.DInt:
|
||||
case AbCipDataType.Dt:
|
||||
_writeBuffer = new byte[4];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(_writeBuffer, Convert.ToInt32(value));
|
||||
break;
|
||||
case AbCipDataType.UDInt:
|
||||
_writeBuffer = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(_writeBuffer, Convert.ToUInt32(value));
|
||||
break;
|
||||
case AbCipDataType.LInt:
|
||||
_writeBuffer = new byte[8];
|
||||
BinaryPrimitives.WriteInt64LittleEndian(_writeBuffer, Convert.ToInt64(value));
|
||||
break;
|
||||
case AbCipDataType.ULInt:
|
||||
_writeBuffer = new byte[8];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(_writeBuffer, Convert.ToUInt64(value));
|
||||
break;
|
||||
case AbCipDataType.Real:
|
||||
_writeBuffer = new byte[4];
|
||||
BinaryPrimitives.WriteSingleLittleEndian(_writeBuffer, Convert.ToSingle(value));
|
||||
break;
|
||||
case AbCipDataType.LReal:
|
||||
_writeBuffer = new byte[8];
|
||||
BinaryPrimitives.WriteDoubleLittleEndian(_writeBuffer, Convert.ToDouble(value));
|
||||
break;
|
||||
case AbCipDataType.String:
|
||||
// The payload needs the capacity learned from a read, which WriteAsync may have to do.
|
||||
_pendingString = Convert.ToString(value) ?? string.Empty;
|
||||
break;
|
||||
case AbCipDataType.Structure:
|
||||
throw new NotSupportedException(
|
||||
"Whole-UDT writes are not supported — address individual member paths instead.");
|
||||
default:
|
||||
throw new NotSupportedException($"AbCipDataType {type} not writable.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the connection lease, waiting at most 2 s so an immediate re-init does not open a
|
||||
/// second session to the device while this one's Forward Close is still in flight (review
|
||||
/// finding 22). Never disposes the shared scheduler; the pool does that on the last release.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
try
|
||||
{
|
||||
if (_lease is { } lease)
|
||||
lease.DisposeAsync().AsTask().Wait(DisposeWait);
|
||||
else if (_privateScheduler is { } scheduler)
|
||||
scheduler.DisposeAsync().AsTask().Wait(DisposeWait);
|
||||
}
|
||||
catch (AggregateException)
|
||||
{
|
||||
// A faulted close is the pool's problem to log; Dispose must not throw.
|
||||
}
|
||||
_lease = null;
|
||||
_privateScheduler = null;
|
||||
}
|
||||
|
||||
private byte[] Path() =>
|
||||
_path ?? throw new InvalidOperationException("InitializeAsync has not been called.");
|
||||
|
||||
private LogixTagClient Client()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
return _client ?? throw new InvalidOperationException("InitializeAsync has not been called.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the controller returned an atomic type other than the authored one. Exempt:
|
||||
/// no authored type, String/Structure, a non-atomic return, a BOOL bit selector against an integer
|
||||
/// host (<c>Flags.3</c> on a DINT), and an authored BOOL answered as 0xD3 (a packed BOOL-array
|
||||
/// DWORD, which is how Logix returns a BOOL-array element), and same-width integers that differ only
|
||||
/// in signedness or are the Micro800 bit-string type of that width (SINT/USINT/BYTE, INT/UINT/WORD,
|
||||
/// DINT/UDINT/DWORD, LINT/ULINT/LWORD): libplctag reads those Good today and a transport cutover
|
||||
/// must not turn them Bad. Different widths and integer↔real still mismatch.
|
||||
/// </summary>
|
||||
private bool IsTypeMismatch(ushort returned)
|
||||
{
|
||||
if (AtomicCode(_p.AuthoredType) is not { } expected) return false;
|
||||
if (LogixTypeCode.ByteWidth(returned) < 0 || returned == expected) return false;
|
||||
if (_p.AuthoredType == AbCipDataType.Bool)
|
||||
{
|
||||
if (returned == LogixTypeCode.BoolArray) return false;
|
||||
if (_implicitBit is not null && IsIntegerCode(returned)) return false;
|
||||
}
|
||||
if (IntegerWidth(expected) is int width && IntegerWidth(returned) == width) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Micro800 bit-string type codes (DWORD is <see cref="LogixTypeCode.BoolArray"/>, 0xD3).</summary>
|
||||
private const ushort ByteCode = 0xD1, WordCode = 0xD2, LWordCode = 0xD4;
|
||||
|
||||
/// <summary>Width of an integer or bit-string code; null for anything else (BOOL, REAL, ...).</summary>
|
||||
private static int? IntegerWidth(ushort code) => code switch
|
||||
{
|
||||
LogixTypeCode.SInt or LogixTypeCode.USInt or ByteCode => 1,
|
||||
LogixTypeCode.Int or LogixTypeCode.UInt or WordCode => 2,
|
||||
LogixTypeCode.DInt or LogixTypeCode.UDInt or LogixTypeCode.BoolArray => 4,
|
||||
LogixTypeCode.LInt or LogixTypeCode.ULInt or LWordCode => 8,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>The Logix type code an authored type expects, or null when it is exempt from checking.</summary>
|
||||
private static ushort? AtomicCode(AbCipDataType? type) => type switch
|
||||
{
|
||||
AbCipDataType.Bool => LogixTypeCode.Bool,
|
||||
AbCipDataType.SInt => LogixTypeCode.SInt,
|
||||
AbCipDataType.USInt => LogixTypeCode.USInt,
|
||||
AbCipDataType.Int => LogixTypeCode.Int,
|
||||
AbCipDataType.UInt => LogixTypeCode.UInt,
|
||||
AbCipDataType.DInt or AbCipDataType.Dt => LogixTypeCode.DInt,
|
||||
AbCipDataType.UDInt => LogixTypeCode.UDInt,
|
||||
AbCipDataType.LInt => LogixTypeCode.LInt,
|
||||
AbCipDataType.ULInt => LogixTypeCode.ULInt,
|
||||
AbCipDataType.Real => LogixTypeCode.Real,
|
||||
AbCipDataType.LReal => LogixTypeCode.LReal,
|
||||
_ => null, // String, Structure
|
||||
};
|
||||
|
||||
private static bool IsIntegerCode(ushort code) => code is LogixTypeCode.SInt or LogixTypeCode.Int
|
||||
or LogixTypeCode.DInt or LogixTypeCode.LInt or LogixTypeCode.USInt or LogixTypeCode.UInt
|
||||
or LogixTypeCode.UDInt or LogixTypeCode.ULInt;
|
||||
|
||||
private static bool IsIntegerShaped(AbCipDataType type) => type is AbCipDataType.Bool or AbCipDataType.SInt
|
||||
or AbCipDataType.USInt or AbCipDataType.Int or AbCipDataType.UInt or AbCipDataType.DInt
|
||||
or AbCipDataType.UDInt or AbCipDataType.LInt or AbCipDataType.ULInt or AbCipDataType.Dt;
|
||||
|
||||
private static object BoxBit(AbCipDataType type, bool bit) => type switch
|
||||
{
|
||||
AbCipDataType.Bool => bit,
|
||||
AbCipDataType.UDInt => bit ? 1u : 0u,
|
||||
AbCipDataType.LInt => bit ? 1L : 0L,
|
||||
AbCipDataType.ULInt => bit ? 1UL : 0UL,
|
||||
_ => bit ? 1 : 0,
|
||||
};
|
||||
|
||||
/// <summary>Bit <paramref name="bit"/> of the little-endian buffer.</summary>
|
||||
private bool GetBit(int bit)
|
||||
{
|
||||
if (bit < 0 || bit / 8 >= _data.Length)
|
||||
throw new AbTransportException(AbStatus.OutOfBounds,
|
||||
$"Bit {bit} is outside the {_data.Length}-byte buffer of '{_p.TagName}'.");
|
||||
return (_data[bit / 8] & (1 << (bit % 8))) != 0;
|
||||
}
|
||||
|
||||
/// <summary>The <paramref name="length"/> bytes at <paramref name="offset"/>; OutOfBounds like libplctag.</summary>
|
||||
private ReadOnlySpan<byte> Span(int offset, int length)
|
||||
{
|
||||
if (offset < 0 || length > _data.Length - offset)
|
||||
throw new AbTransportException(AbStatus.OutOfBounds,
|
||||
$"Offset {offset} + {length} byte(s) is outside the {_data.Length}-byte buffer of '{_p.TagName}'.");
|
||||
return _data.AsSpan(offset, length);
|
||||
}
|
||||
|
||||
/// <summary>Logix STRING: <c>u32 LEN</c> then ASCII DATA, LEN clamped to what the buffer holds.</summary>
|
||||
private string DecodeString(int offset)
|
||||
{
|
||||
var len = BinaryPrimitives.ReadUInt32LittleEndian(Span(offset, StringLengthBytes));
|
||||
var available = _data.Length - offset - StringLengthBytes;
|
||||
var count = (int)Math.Min(len, (uint)available);
|
||||
return Encoding.ASCII.GetString(_data, offset + StringLengthBytes, count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>u32 len</c> + DATA padded to the capacity learned from the last read (data length − 4), so the
|
||||
/// write carries the whole structure as the controller sized it.
|
||||
/// </summary>
|
||||
private byte[] EncodeStringPayload(string text)
|
||||
{
|
||||
var capacity = _data.Length - StringLengthBytes;
|
||||
var bytes = Encoding.ASCII.GetBytes(text);
|
||||
if (capacity < 0 || bytes.Length > capacity)
|
||||
throw new AbTransportException(AbStatus.TooLarge,
|
||||
$"STRING write to '{_p.TagName}': {bytes.Length} character(s) exceeds the {Math.Max(capacity, 0)}-byte capacity.");
|
||||
var payload = new byte[StringLengthBytes + capacity];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, (uint)bytes.Length);
|
||||
bytes.CopyTo(payload, StringLengthBytes);
|
||||
return payload;
|
||||
}
|
||||
|
||||
private bool[] BuildBoolArray(int count)
|
||||
{
|
||||
var arr = new bool[count];
|
||||
for (var i = 0; i < count; i++) arr[i] = GetBit(i);
|
||||
return arr;
|
||||
}
|
||||
|
||||
private T[] BuildArray<T>(int count, int stride, AbCipDataType type)
|
||||
{
|
||||
var arr = new T[count];
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var decoded = DecodeAt(type, i * stride, null);
|
||||
arr[i] = decoded is null ? default! : (T)decoded;
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Native <see cref="IAbCipTemplateReader"/>: Get Attribute List then chunked Read Template on the
|
||||
/// device's shared scheduler, returning the 14-byte-header buffer <see cref="CipTemplateObjectDecoder"/>
|
||||
/// parses. The lease is held for one read and released when it completes.
|
||||
/// </summary>
|
||||
internal sealed class NativeAbCipTemplateReader : IAbCipTemplateReader
|
||||
{
|
||||
/// <summary>Request bytes of a Read Template that are not data room (service, path, offset, count).</summary>
|
||||
private const int RequestOverhead = 16;
|
||||
|
||||
/// <summary>Bytes of the template definition header not counted in the Read Template length.</summary>
|
||||
private const int DefinitionHeaderBytes = 23;
|
||||
|
||||
/// <summary>Upper bound on a template's Read Template length; Logix templates are far smaller.</summary>
|
||||
internal const long MaxTemplateBytes = 64 * 1024;
|
||||
|
||||
private readonly Func<AbCipTagCreateParams, (RequestScheduler Scheduler, ICipChannel Channel, Func<ValueTask> Release)> _acquire;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>Production constructor: leases from <paramref name="pool"/>.</summary>
|
||||
/// <param name="pool">The connection pool (the factory passes <see cref="DeviceSessionPool.Shared"/>).</param>
|
||||
public NativeAbCipTemplateReader(DeviceSessionPool pool)
|
||||
: this(p =>
|
||||
{
|
||||
var lease = pool.Acquire(NativeAbCipDevice.Key(p), NativeAbCipDevice.Options(p));
|
||||
return (lease.Scheduler, lease.Channel, lease.DisposeAsync);
|
||||
})
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam: supplies the scheduler, channel and release directly.</summary>
|
||||
/// <param name="acquire">Acquires the connection parts for a device.</param>
|
||||
/// <param name="logger">Receives release failures; null discards them.</param>
|
||||
internal NativeAbCipTemplateReader(
|
||||
Func<AbCipTagCreateParams, (RequestScheduler Scheduler, ICipChannel Channel, Func<ValueTask> Release)> acquire,
|
||||
ILogger? logger = null)
|
||||
{
|
||||
_acquire = acquire;
|
||||
_logger = logger ?? NullLogger.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> ReadAsync(
|
||||
AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (templateInstanceId > ushort.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(templateInstanceId), "Template ids are 16-bit.");
|
||||
var id = (ushort)templateInstanceId;
|
||||
|
||||
var (scheduler, channel, release) = _acquire(deviceParams);
|
||||
try
|
||||
{
|
||||
var attrReply = await scheduler.SendAsync(TemplateObjectServices.GetAttributes(id), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var attrs = TemplateObjectServices.ParseAttributes(attrReply);
|
||||
|
||||
// The CIP-documented Read Template length: definition words * 4 - 23.
|
||||
var total = checked((long)attrs.DefinitionWords * 4 - DefinitionHeaderBytes);
|
||||
if (total < 0 || total > MaxTemplateBytes)
|
||||
throw new AbTransportException(AbStatus.BadReply,
|
||||
$"Template {id} reports an impossible definition size of {attrs.DefinitionWords} words.");
|
||||
|
||||
using var raw = new MemoryStream();
|
||||
long offset = 0;
|
||||
while (offset < total)
|
||||
{
|
||||
var chunk = (ushort)Math.Min(total - offset, channel.MaxRequestBytes - RequestOverhead);
|
||||
var reply = await scheduler
|
||||
.SendAsync(TemplateObjectServices.ReadTemplate(id, (uint)offset, chunk), cancellationToken, exclusive: true)
|
||||
.ConfigureAwait(false);
|
||||
if (reply.GeneralStatus is not (0x00 or 0x06))
|
||||
throw new AbTransportException(reply.Status,
|
||||
$"Read Template {id} failed: general 0x{reply.GeneralStatus:X2}.");
|
||||
var more = TemplateObjectServices.ReadHasMore(reply);
|
||||
if (more && reply.Data.IsEmpty)
|
||||
throw new AbTransportException(AbStatus.BadReply, $"Read Template {id} reported more data but returned none.");
|
||||
raw.Write(reply.Data.Span);
|
||||
offset += reply.Data.Length;
|
||||
if (!more) break;
|
||||
}
|
||||
|
||||
return TemplateObjectServices.BuildDecoderBuffer(id, attrs, raw.ToArray());
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeLease.ReleaseBounded(release, _logger);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Nothing to release — each read owns and releases its own lease.</summary>
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
/// <summary>Factory for <see cref="NativeAbCipTemplateReader"/>.</summary>
|
||||
internal sealed class NativeAbCipTemplateReaderFactory : IAbCipTemplateReaderFactory
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public IAbCipTemplateReader Create() => new NativeAbCipTemplateReader(DeviceSessionPool.Shared);
|
||||
}
|
||||
@@ -41,7 +41,12 @@ public sealed record AbCipPlcFamilyProfile(
|
||||
|
||||
public static readonly AbCipPlcFamilyProfile CompactLogix = new(
|
||||
LibplctagPlcAttribute: "compactlogix",
|
||||
DefaultConnectionSize: 504, // 5069-L3x narrower buffer; safe baseline that never over-shoots
|
||||
// Large Forward Open, matching libplctag: `plc=compactlogix` opens Large (0x5B, 4002) and the
|
||||
// native transport must not pack into a third of the window (T21 latency harness: 504 cost ~3x
|
||||
// the round trips per poll). A pre-v20 CompactLogix (1769-L3x) or a bridge that refuses the Large open
|
||||
// is covered by CipConnection's fallback — a regular open at min(size, 504) inside the same
|
||||
// throttled attempt. An explicit ConnectionSize (driver options or per device) still wins.
|
||||
DefaultConnectionSize: 4002,
|
||||
DefaultCipPath: "1,0",
|
||||
SupportsRequestPacking: true,
|
||||
SupportsConnectedMessaging: true,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-neutral <see cref="IAbCipTagEnumerator"/> that walks a Logix controller's symbol table
|
||||
/// by reading the <c>@tags</c> listing through an injected reader + decoding the CIP Symbol Object
|
||||
/// bytes with <see cref="CipSymbolObjectDecoder"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The reader returns the raw Symbol Object bytes for a listing name (libplctag's
|
||||
/// <c>Tag.GetBuffer()</c> for <c>@tags</c>, or the native paged Instance Attribute List). The decoder walks the concatenated entries + emits
|
||||
/// <see cref="AbCipDiscoveredTag"/> records matching our driver surface.</para>
|
||||
///
|
||||
/// <para><see cref="EmptyAbCipTagEnumerator"/> remains available for tests that don't want
|
||||
/// to touch the native library; the production factory default
|
||||
/// (<see cref="LibplctagTagEnumeratorFactory"/>) wires this implementation in.</para>
|
||||
/// </remarks>
|
||||
internal sealed class SymbolListingEnumerator : IAbCipTagEnumerator
|
||||
{
|
||||
private readonly Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> _readListing;
|
||||
|
||||
/// <summary>Enumerator over a transport-specific listing reader.</summary>
|
||||
/// <param name="readListing">Reads one symbol listing (<c>@tags</c> or <c>Program:X.@tags</c>) and
|
||||
/// returns its raw Symbol Object bytes.</param>
|
||||
public SymbolListingEnumerator(Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> readListing)
|
||||
=> _readListing = readListing;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A controller's <c>@tags</c> listing holds controller-scope tags plus one system entry per
|
||||
/// program (<c>Program:UN01_Press</c>); the program's own tags are listed only by
|
||||
/// <c>Program:<name>.@tags</c>. So the controller listing is read first, then each program's
|
||||
/// listing, decoded with that program as the scope. A program whose listing cannot be read is
|
||||
/// skipped — browse is best-effort and one unreadable program must not cost the others.
|
||||
/// </remarks>
|
||||
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = await _readListing(deviceParams, "@tags", cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// The decoder already surfaces each struct tag's CIP template instance id on
|
||||
// AbCipDiscoveredTag.TemplateInstanceId (from the lower 12 bits of the symbol type), so
|
||||
// re-yielding the decoded records as-is carries it through to DiscoverAsync's UDT fan-out.
|
||||
var programs = new List<string>();
|
||||
foreach (var tag in CipSymbolObjectDecoder.Decode(buffer))
|
||||
{
|
||||
if (CipSymbolObjectDecoder.ProgramNameOf(tag) is { } program) programs.Add(program);
|
||||
yield return tag;
|
||||
}
|
||||
|
||||
foreach (var program in programs)
|
||||
{
|
||||
byte[] programBuffer;
|
||||
try
|
||||
{
|
||||
programBuffer = await _readListing(deviceParams, $"Program:{program}.@tags", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var tag in CipSymbolObjectDecoder.Decode(programBuffer, programScope: program))
|
||||
yield return tag;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Nothing to release — the reader owns any per-listing resources.</summary>
|
||||
public void Dispose() { }
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts\ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
@@ -27,6 +28,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -25,6 +25,23 @@ public sealed class AbLegacyDriverOptions
|
||||
/// </summary>
|
||||
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Which transport stack the driver, its Test Connect probe and the CLI use. <c>null</c> (the
|
||||
/// <c>transport</c> key absent) means "use the default": <see cref="AbLegacyTransport.Libplctag"/> for
|
||||
/// every family. <see cref="AbLegacyTransport.Native"/> is opt-in (final review I3: on a wire it is proven
|
||||
/// only for N-file reads/writes and one F8 read). An explicit value always wins. The
|
||||
/// effective transport is resolved in one place, <c>AbLegacyTransportResolver</c>.
|
||||
/// <para>Changing this takes effect when the driver instance is RECREATED, not on
|
||||
/// <c>ReinitializeAsync</c>: the tag factory is chosen once in the constructor and held across
|
||||
/// Shutdown+Initialize. A deploy that changes the driver config stops and respawns the instance
|
||||
/// (<c>DriverSpawnPlanner</c>, #516), so an operator edit does recreate it.</para>
|
||||
/// </summary>
|
||||
/// <remarks>Null is never written: an absent key IS the "use the default" encoding, so a round-trip
|
||||
/// through any serializer (the AdminUI form included) keeps a defaulted driver defaulted rather than
|
||||
/// pinning it to whatever the default happened to be on the day it was saved.</remarks>
|
||||
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
|
||||
public AbLegacyTransport? Transport { get; init; }
|
||||
|
||||
/// <summary>Gets or sets the probe (connectivity check) options.</summary>
|
||||
public AbLegacyProbeOptions Probe { get; init; } = new();
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Which PCCC transport stack an AbLegacy driver instance (and its Test Connect probe / CLI run)
|
||||
/// talks to the controller through. Serialises as a STRING in driver config JSON.
|
||||
/// </summary>
|
||||
public enum AbLegacyTransport
|
||||
{
|
||||
/// <summary>The libplctag-backed transport. The default.</summary>
|
||||
Libplctag,
|
||||
|
||||
/// <summary>
|
||||
/// The native managed EtherNet/IP + PCCC transport. Known gaps: PLC-5 supports N-file reads only,
|
||||
/// writes to status-bit sub-elements are unsupported, and LogixPccc framing is unverified.
|
||||
/// </summary>
|
||||
Native,
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
@@ -68,7 +69,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
_tagFactory = tagFactory ?? new LibplctagLegacyTagFactory();
|
||||
// Chosen once here and held across ReinitializeAsync (Shutdown+Initialize): a changed
|
||||
// Transport in a reinit config is inert; a deploy recreates the instance instead.
|
||||
_transport = AbLegacyTransportResolver.Resolve(options);
|
||||
_transportDescription = AbLegacyTransportResolver.Describe(options.Transport);
|
||||
_tagFactory = tagFactory ?? (_transport == AbLegacyTransport.Native
|
||||
? new NativeAbLegacyTagFactory()
|
||||
: new LibplctagLegacyTagFactory());
|
||||
_logger = logger ?? NullLogger<AbLegacyDriver>.Instance;
|
||||
_resolver = new EquipmentTagRefResolver<AbLegacyTagDefinition>(
|
||||
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
|
||||
@@ -85,6 +92,23 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
new KeyValuePair<string, object?>(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, driverInstanceId)));
|
||||
}
|
||||
|
||||
/// <summary>The tag factory chosen at construction; exposed so tests can pin transport selection.</summary>
|
||||
internal IAbLegacyTagFactory TagFactory => _tagFactory;
|
||||
|
||||
/// <summary>The effective transport resolved at construction (see <see cref="AbLegacyTransportResolver"/>).
|
||||
/// Describes the OPTION only: an injected <c>tagFactory</c> (tests) is used regardless.</summary>
|
||||
internal AbLegacyTransport EffectiveTransport => _transport;
|
||||
|
||||
// The transport chosen at construction (see the ctor); logged at every init so an operator can tell
|
||||
// from the node log which stack serves this instance.
|
||||
private readonly AbLegacyTransport _transport;
|
||||
|
||||
// Configured-vs-effective rendering of _transport for the init log, e.g. "Libplctag (default)".
|
||||
private readonly string _transportDescription;
|
||||
|
||||
// Set once the explicit-Native-on-a-limited-family Warning has been logged for this instance.
|
||||
private bool _limitedFamilyWarned;
|
||||
|
||||
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
|
||||
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
|
||||
|
||||
@@ -122,6 +146,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
_logger.LogInformation(
|
||||
"AbLegacy driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}",
|
||||
_driverInstanceId, _transportDescription, _tagFactory.GetType().Name);
|
||||
try
|
||||
{
|
||||
// #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver
|
||||
@@ -130,6 +157,21 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
if (HasConfigBody(driverConfigJson))
|
||||
_options = AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
|
||||
|
||||
// Native is opt-in for every family (the default is libplctag); an operator may pick it explicitly
|
||||
// even for PLC-5 / LogixPccc. Allowed, but the limitation is client-visible, so name it ONCE per instance —
|
||||
// not on every init, which includes every comms-loss reconnect retry (Task 22 review r1 M4).
|
||||
if (!_limitedFamilyWarned
|
||||
&& _transport == AbLegacyTransport.Native
|
||||
&& AbLegacyTransportResolver.NativeLimitedFamilies(_options.Devices) is { Count: > 0 } limited)
|
||||
{
|
||||
_limitedFamilyWarned = true;
|
||||
_logger.LogWarning(
|
||||
"AbLegacy driver {DriverInstanceId} runs the Native transport against {Families} device(s); native " +
|
||||
"support is limited there (PLC-5: N-file reads only; LogixPccc: framing unverified). Set " +
|
||||
"\"transport\": \"Libplctag\" to use the fallback stack.",
|
||||
_driverInstanceId, string.Join(", ", limited));
|
||||
}
|
||||
|
||||
foreach (var device in _options.Devices)
|
||||
{
|
||||
var addr = AbLegacyHostAddress.TryParse(device.HostAddress)
|
||||
@@ -315,14 +357,14 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
// Evict the stale handle so the next call re-creates it (mirrors AbCip).
|
||||
EvictRuntime(device, def.Name);
|
||||
results[i] = new DataValueSnapshot(null,
|
||||
AbLegacyStatusMapper.MapLibplctagStatus(status), null, now);
|
||||
AbLegacyStatusMapper.MapStatus(status), null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"libplctag status {status} reading {reference}");
|
||||
$"transport status {status} reading {reference}");
|
||||
if (!device.FirstNonZeroStatusLogged)
|
||||
{
|
||||
device.FirstNonZeroStatusLogged = true;
|
||||
_logger.LogWarning(
|
||||
"AbLegacy non-zero libplctag status. Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Status={Status}",
|
||||
"AbLegacy non-zero transport status. Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Status={Status}",
|
||||
_driverInstanceId, def.DeviceHostAddress, reference, status);
|
||||
}
|
||||
continue;
|
||||
@@ -335,6 +377,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (AbTransportException ex) when (IsLocalRefusal(ex))
|
||||
{
|
||||
// The runtime refused the operation before the wire (e.g. a native PLC-5 non-N read, #571).
|
||||
// Not a comms fault: BadNotSupported, keep the handle, leave health alone.
|
||||
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Transport exception — evict so the next read creates a fresh handle (mirrors AbCip).
|
||||
@@ -416,7 +464,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
{
|
||||
// Evict the stale handle so the next call re-creates it (mirrors AbCip).
|
||||
EvictRuntime(device, def.Name);
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.MapLibplctagStatus(status));
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(status));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -437,6 +485,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
|
||||
}
|
||||
catch (AbTransportException ex) when (IsLocalRefusal(ex))
|
||||
{
|
||||
// The runtime refused the write before the wire — a native status-bit sub-element write or a
|
||||
// PLC-5 write (#571). Not a comms fault: BadNotSupported, keep the handle, leave health alone.
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Transport exception — evict so the next write/read creates a fresh handle (mirrors AbCip).
|
||||
@@ -654,7 +708,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
|
||||
await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
|
||||
var readStatus = parentRuntime.GetStatus();
|
||||
if (readStatus != 0) return AbLegacyStatusMapper.MapLibplctagStatus(readStatus);
|
||||
if (readStatus != 0) return AbLegacyStatusMapper.MapStatus(readStatus);
|
||||
|
||||
// Mask the decoded word to its native width: DecodeValue for a 16-bit Int returns a
|
||||
// sign-extended int, so a word with bit 15 set decodes negative. Masking pins the
|
||||
@@ -673,7 +727,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
var writeStatus = parentRuntime.GetStatus();
|
||||
return writeStatus == 0
|
||||
? AbLegacyStatusMapper.Good
|
||||
: AbLegacyStatusMapper.MapLibplctagStatus(writeStatus);
|
||||
: AbLegacyStatusMapper.MapStatus(writeStatus);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -703,16 +757,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: parentName,
|
||||
Timeout: _options.Timeout));
|
||||
try
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
device.ParentRuntimes[parentName] = runtime;
|
||||
await InitializeOrKeepRefusedAsync(runtime, () => device.ParentRuntimes[parentName] = runtime, ct)
|
||||
.ConfigureAwait(false);
|
||||
return runtime;
|
||||
}
|
||||
finally
|
||||
@@ -739,6 +785,39 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
private static int EffectiveArrayLength(AbLegacyTagDefinition def) =>
|
||||
def.ArrayLength is int len && len >= 1 ? Math.Min(len, AbLegacyArray.MaxElements) : 1;
|
||||
|
||||
/// <summary>
|
||||
/// True for a deterministic refusal of the operation itself (<see cref="AbStatus.Unsupported"/>) — the
|
||||
/// native runtime raises it BEFORE anything reaches the wire (status-bit writes, PLC-5 non-N reads and
|
||||
/// all PLC-5 writes), and libplctag's ErrorUnsupported maps to it too. It says nothing about the
|
||||
/// device's reachability, so it must not evict the handle or degrade driver health.
|
||||
/// </summary>
|
||||
internal static bool IsLocalRefusal(Exception ex) =>
|
||||
ex is AbTransportException { Status: AbStatus.Unsupported };
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a freshly created runtime. A NATIVE runtime whose init (one read) is refused locally is
|
||||
/// still KEPT: the refusal is deterministic and per-operation, so caching it avoids re-creating — and
|
||||
/// re-leasing the pooled device connection — on every poll. Any other failure disposes it, as before.
|
||||
/// </summary>
|
||||
private static async Task InitializeOrKeepRefusedAsync(IAbLegacyTagRuntime runtime, Action keep, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (IsLocalRefusal(ex) && runtime is NativeAbLegacyTagRuntime)
|
||||
{
|
||||
keep();
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
keep();
|
||||
}
|
||||
|
||||
private async Task<IAbLegacyTagRuntime> EnsureTagRuntimeAsync(
|
||||
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
|
||||
{
|
||||
@@ -765,16 +844,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
TagName: parsed.ToLibplctagName(),
|
||||
Timeout: _options.Timeout,
|
||||
ElementCount: EffectiveArrayLength(def)));
|
||||
try
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
device.Runtimes[def.Name] = runtime;
|
||||
await InitializeOrKeepRefusedAsync(runtime, () => device.Runtimes[def.Name] = runtime, ct)
|
||||
.ConfigureAwait(false);
|
||||
return runtime;
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -97,6 +97,11 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
|
||||
},
|
||||
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
|
||||
// Absent ⇒ null ("use the default" = Libplctag, resolved by AbLegacyTransportResolver);
|
||||
// a bad value still fails exactly as any other bad enum field does.
|
||||
Transport = string.IsNullOrWhiteSpace(dto.Transport)
|
||||
? null
|
||||
: ParseEnum<AbLegacyTransport>(dto.Transport, driverInstanceId, "Transport"),
|
||||
};
|
||||
|
||||
return options;
|
||||
@@ -125,7 +130,8 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
throw new InvalidOperationException(
|
||||
$"AB Legacy {(tagName is null ? "config" : $"tag '{tagName}'")} in '{driverInstanceId}' missing {field}");
|
||||
}
|
||||
return Enum.TryParse<T>(raw, ignoreCase: true, out var v)
|
||||
// Enum.TryParse also accepts any integer string ("7"), defined or not (Task 22 review r1 M2).
|
||||
return Enum.TryParse<T>(raw, ignoreCase: true, out var v) && Enum.IsDefined(v)
|
||||
? v
|
||||
: throw new InvalidOperationException(
|
||||
$"AB Legacy {(tagName is null ? "config" : $"tag '{tagName}'")} has unknown {field} '{raw}'. " +
|
||||
@@ -146,6 +152,11 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
/// </summary>
|
||||
public int? TimeoutMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the transport (<c>Libplctag</c> or <c>Native</c>, case-insensitive); absent means Libplctag (the default).
|
||||
/// </summary>
|
||||
public string? Transport { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of devices to connect to.
|
||||
/// </summary>
|
||||
|
||||
@@ -2,7 +2,7 @@ using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using libplctag;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
@@ -18,23 +18,25 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
/// non-PCCC server) returns <c>Ok = false</c> with a "handshake failed" message instead of
|
||||
/// a false-positive green tick.
|
||||
/// <para>
|
||||
/// Status-code discrimination: libplctag statuses that indicate the controller answered
|
||||
/// Status-code discrimination: transport statuses that indicate the controller answered
|
||||
/// the PCCC session but could not resolve the probe tag
|
||||
/// (<see cref="Status.ErrorNotFound"/>, <see cref="Status.ErrorNoMatch"/>,
|
||||
/// <see cref="Status.ErrorBadDevice"/>) are treated as "reachable — Ok = true" because
|
||||
/// (<see cref="AbStatus.NotFound"/>) are treated as "reachable — Ok = true" because
|
||||
/// a non-existent tag name proves PCCC connectivity. Statuses that indicate a transport or
|
||||
/// session failure (<see cref="Status.ErrorTimeout"/>, <see cref="Status.ErrorBadConnection"/>,
|
||||
/// <see cref="Status.ErrorBadGateway"/>, <see cref="Status.ErrorWinsock"/>,
|
||||
/// <see cref="Status.ErrorOpen"/>, <see cref="Status.ErrorClose"/>,
|
||||
/// <see cref="Status.ErrorRead"/>, <see cref="Status.ErrorWrite"/>,
|
||||
/// <see cref="Status.ErrorBadReply"/>, <see cref="Status.ErrorRemoteErr"/>,
|
||||
/// <see cref="Status.ErrorPartial"/>, <see cref="Status.ErrorAbort"/>) are treated as
|
||||
/// session failure (<see cref="AbStatus.Timeout"/>, <see cref="AbStatus.BadConnection"/>,
|
||||
/// <see cref="AbStatus.BadReply"/>, <see cref="AbStatus.Partial"/>, <see cref="AbStatus.Abort"/>) are treated as
|
||||
/// "handshake failed — Ok = false". All other error statuses default to
|
||||
/// "handshake failed" (conservative).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AbLegacyDriverProbe : IDriverProbe
|
||||
{
|
||||
private readonly IAbLegacyTagFactory? _tagFactory;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="AbLegacyDriverProbe"/> class.</summary>
|
||||
/// <param name="tagFactory">The tag factory used for the PCCC handshake; when null the config's
|
||||
/// <c>Transport</c> selects the native or libplctag-backed one.</param>
|
||||
public AbLegacyDriverProbe(IAbLegacyTagFactory? tagFactory = null) => _tagFactory = tagFactory;
|
||||
|
||||
private static readonly JsonSerializerOptions _opts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
@@ -104,7 +106,10 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
|
||||
TagName: tagName,
|
||||
Timeout: timeout);
|
||||
|
||||
var rt = new LibplctagLegacyTagRuntime(p);
|
||||
var factory = _tagFactory ?? (AbLegacyTransportResolver.Resolve(opts) == AbLegacyTransport.Native
|
||||
? new NativeAbLegacyTagFactory()
|
||||
: new LibplctagLegacyTagFactory());
|
||||
var rt = factory.Create(p);
|
||||
try
|
||||
{
|
||||
await rt.InitializeAsync(cts.Token);
|
||||
@@ -112,10 +117,10 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
|
||||
|
||||
// InitializeAsync completed without throwing — either the tag was found (Ok) or
|
||||
// libplctag returned a non-exception status. Check via GetStatus().
|
||||
var statusCode = (Status)rt.GetStatus();
|
||||
var statusCode = (AbStatus)rt.GetStatus();
|
||||
if (IsReachableStatus(statusCode))
|
||||
{
|
||||
var msg = statusCode == Status.Ok
|
||||
var msg = statusCode == AbStatus.Ok
|
||||
? "PCCC session OK"
|
||||
: "PCCC session OK (controller reachable; probe tag not found)";
|
||||
return new(true, msg, sw.Elapsed);
|
||||
@@ -128,10 +133,10 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
|
||||
{
|
||||
return new(false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
|
||||
}
|
||||
catch (LibPlcTagException ex)
|
||||
catch (AbTransportException ex)
|
||||
{
|
||||
// LibPlcTagException carries the Status; classify as reachable vs transport failure.
|
||||
if (IsReachableException(ex))
|
||||
// AbTransportException carries the Status; classify as reachable vs transport failure.
|
||||
if (IsReachableStatus(ex.Status))
|
||||
{
|
||||
sw.Stop();
|
||||
return new(true, "PCCC session OK (controller reachable; probe tag not found)", sw.Elapsed);
|
||||
@@ -150,30 +155,13 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> for libplctag statuses that indicate the PCCC controller answered
|
||||
/// but the probe tag name was not found or did not match — sufficient to confirm PCCC
|
||||
/// reachability. Returns <c>true</c> for <see cref="Status.Ok"/> (tag was found).
|
||||
/// Returns <c>true</c> for statuses that indicate the PCCC controller answered but the
|
||||
/// probe tag was not found — sufficient to confirm PCCC reachability. Also <c>true</c> for
|
||||
/// <see cref="AbStatus.Ok"/> (tag was found).
|
||||
/// </summary>
|
||||
private static bool IsReachableStatus(Status s) => s is
|
||||
Status.Ok or
|
||||
Status.ErrorNotFound or
|
||||
Status.ErrorNoMatch or
|
||||
Status.ErrorBadDevice;
|
||||
|
||||
/// <summary>
|
||||
/// Inspects a <see cref="LibPlcTagException"/> message to decide whether the error is a
|
||||
/// "controller answered PCCC but tag not found" condition (reachable) versus a session /
|
||||
/// transport failure (unreachable). Falls back to checking the exception message for
|
||||
/// known tag-not-found phrases used by the libplctag native library.
|
||||
/// </summary>
|
||||
private static bool IsReachableException(LibPlcTagException ex)
|
||||
{
|
||||
// libplctag wraps the Status in the message as "STATUS_NOT_FOUND", "STATUS_NO_MATCH", etc.
|
||||
var msg = ex.Message ?? string.Empty;
|
||||
return msg.Contains("NOT_FOUND", StringComparison.OrdinalIgnoreCase)
|
||||
|| msg.Contains("NO_MATCH", StringComparison.OrdinalIgnoreCase)
|
||||
|| msg.Contains("BAD_DEVICE", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
/// <param name="s">The transport status.</param>
|
||||
/// <returns>Whether the status proves the controller answered.</returns>
|
||||
internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
|
||||
|
||||
private static (AbLegacyHostAddress? parsed, AbLegacyDeviceOptions? device) ExtractTarget(AbLegacyDriverOptions opts)
|
||||
{
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using libplctag;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Maps libplctag status codes + PCCC STS/EXT_STS bytes to OPC UA StatusCodes. Mirrors the
|
||||
/// Maps transport status codes + PCCC STS/EXT_STS bytes to OPC UA StatusCodes. Mirrors the
|
||||
/// AbCip mapper — PCCC errors roughly align with CIP general-status in shape but with a
|
||||
/// different byte vocabulary (PCCC STS nibble-low + EXT_STS on code 0x0F).
|
||||
/// </summary>
|
||||
@@ -22,44 +22,41 @@ public static class AbLegacyStatusMapper
|
||||
public const uint BadTypeMismatch = 0x80740000u;
|
||||
|
||||
/// <summary>
|
||||
/// Map a libplctag return/status code to an OPC UA StatusCode. The integer passed here
|
||||
/// is <c>(int)Tag.GetStatus()</c> — the underlying value of the libplctag.NET
|
||||
/// <see cref="Status"/> enum. Delegates to the strongly-typed overload so the mapping
|
||||
/// stays correct regardless of how the wrapper renumbers native PLCTAG_ERR_* constants
|
||||
/// in future releases.
|
||||
/// Map the integer form of an <see cref="AbStatus"/> (what <see cref="IAbLegacyTagRuntime.GetStatus"/>
|
||||
/// returns) to an OPC UA StatusCode.
|
||||
/// </summary>
|
||||
/// <param name="status">The integer status code from libplctag.</param>
|
||||
/// <param name="status">The integer transport status.</param>
|
||||
/// <returns>The corresponding OPC UA status code.</returns>
|
||||
public static uint MapLibplctagStatus(int status) => MapLibplctagStatus((Status)status);
|
||||
public static uint MapStatus(int status) => MapStatus((AbStatus)status);
|
||||
|
||||
/// <summary>
|
||||
/// Map a libplctag.NET <see cref="Status"/> enum value to an OPC UA StatusCode. This is
|
||||
/// the canonical core; the <c>int</c> overload exists only for the
|
||||
/// <see cref="IAbLegacyTagRuntime.GetStatus"/> seam which boxes the enum as an int.
|
||||
/// </summary>
|
||||
/// <param name="status">The libplctag Status enum value.</param>
|
||||
/// <summary>Map a transport-neutral <see cref="AbStatus"/> to an OPC UA StatusCode.</summary>
|
||||
/// <param name="status">The transport status.</param>
|
||||
/// <returns>The corresponding OPC UA status code.</returns>
|
||||
public static uint MapLibplctagStatus(Status status) => status switch
|
||||
public static uint MapStatus(AbStatus status) => status switch
|
||||
{
|
||||
Status.Ok => Good,
|
||||
Status.Pending => GoodMoreData,
|
||||
Status.ErrorTimeout => BadTimeout,
|
||||
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => BadNodeIdUnknown,
|
||||
Status.ErrorNotAllowed => BadNotWritable,
|
||||
Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => BadOutOfRange,
|
||||
Status.ErrorUnsupported or Status.ErrorNotImplemented => BadNotSupported,
|
||||
Status.ErrorBadConnection or Status.ErrorBadGateway or Status.ErrorBadReply
|
||||
or Status.ErrorWinsock or Status.ErrorOpen or Status.ErrorClose
|
||||
or Status.ErrorRead or Status.ErrorWrite or Status.ErrorRemoteErr
|
||||
or Status.ErrorPartial or Status.ErrorAbort => BadCommunicationError,
|
||||
_ => BadCommunicationError,
|
||||
AbStatus.Ok => Good,
|
||||
AbStatus.Pending => GoodMoreData,
|
||||
AbStatus.Timeout => BadTimeout,
|
||||
AbStatus.NotFound => BadNodeIdUnknown,
|
||||
AbStatus.NotAllowed => BadNotWritable,
|
||||
AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall => BadOutOfRange,
|
||||
AbStatus.Unsupported => BadNotSupported,
|
||||
AbStatus.TypeMismatch => BadTypeMismatch,
|
||||
AbStatus.DeviceStateConflict => BadDeviceFailure,
|
||||
_ => BadCommunicationError, // BadConnection, BadReply, Partial, Abort
|
||||
};
|
||||
|
||||
/// <summary>Map an exception thrown by a transport to an OPC UA StatusCode.</summary>
|
||||
/// <param name="ex">The exception.</param>
|
||||
/// <returns>The mapped status for an <see cref="AbTransportException"/>, else BadCommunicationError.</returns>
|
||||
public static uint MapException(Exception ex) =>
|
||||
ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError;
|
||||
|
||||
/// <summary>
|
||||
/// Map a PCCC STS (status) byte. Common codes per AB PCCC reference:
|
||||
/// 0x00 = success, 0x10 = illegal command, 0x20 = bad address, 0x30 = protected,
|
||||
/// 0x40 = programmer busy, 0x50 = file locked, 0xF0 = extended status follows.
|
||||
/// libplctag surfaces only its own <see cref="Status"/> enum rather than exposing
|
||||
/// the transports surface only an <see cref="AbStatus"/> rather than exposing
|
||||
/// the raw STS byte, so this method is not wired into the current read/write path.
|
||||
/// It is retained as the reference mapping for future PCCC-STS inspection.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// The ONE place an AbLegacy driver's effective transport is decided from its configured
|
||||
/// <see cref="AbLegacyDriverOptions.Transport"/>. The driver constructor, the Test Connect probe and the
|
||||
/// CLI (through the driver) all resolve through here.
|
||||
/// <para>An absent key means <see cref="Default"/> = <see cref="AbLegacyTransport.Libplctag"/> for EVERY
|
||||
/// family (final review I3): on a wire the native PCCC path is proven only for N-file reads and writes
|
||||
/// and one F8 read, so Native stays opt-in. An explicit value always wins (an operator choice). An
|
||||
/// explicit Native on a PLC-5 / LogixPccc device is allowed but logs a once-per-instance Warning
|
||||
/// (<see cref="NativeLimitedFamilies"/>).</para>
|
||||
/// </summary>
|
||||
public static class AbLegacyTransportResolver
|
||||
{
|
||||
/// <summary>Returns true for a family whose native transport support is limited.</summary>
|
||||
/// <param name="family">The PLC family.</param>
|
||||
/// <returns>True for PLC-5 and LogixPccc.</returns>
|
||||
public static bool IsNativeLimited(AbLegacyPlcFamily family) =>
|
||||
family is AbLegacyPlcFamily.Plc5 or AbLegacyPlcFamily.LogixPccc;
|
||||
|
||||
/// <summary>Returns the distinct native-limited families among <paramref name="devices"/>.</summary>
|
||||
/// <param name="devices">The configured devices.</param>
|
||||
/// <returns>The limited families present, in declaration order.</returns>
|
||||
public static IReadOnlyList<AbLegacyPlcFamily> NativeLimitedFamilies(IEnumerable<AbLegacyDeviceOptions> devices)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(devices);
|
||||
return [.. devices.Select(d => d.PlcFamily).Where(IsNativeLimited).Distinct()];
|
||||
}
|
||||
|
||||
/// <summary>The transport an absent / null <c>transport</c> key selects (all families).</summary>
|
||||
public const AbLegacyTransport Default = AbLegacyTransport.Libplctag;
|
||||
|
||||
/// <summary>Returns the effective transport: an explicit value always wins; null means <see cref="Default"/>.</summary>
|
||||
/// <param name="configured">The configured transport, or null for "use the default".</param>
|
||||
/// <returns>The transport the driver runs on.</returns>
|
||||
public static AbLegacyTransport Resolve(AbLegacyTransport? configured) => configured ?? Default;
|
||||
|
||||
/// <summary>Returns the effective transport for <paramref name="options"/>.</summary>
|
||||
/// <param name="options">The driver options.</param>
|
||||
/// <returns>The transport the driver runs on.</returns>
|
||||
public static AbLegacyTransport Resolve(AbLegacyDriverOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return Resolve(options.Transport);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operator-facing description of the configured-vs-effective transport for the init log line:
|
||||
/// <c>Libplctag (default)</c> when the key was absent, <c>Native (configured)</c> when explicit.
|
||||
/// </summary>
|
||||
/// <param name="configured">The configured transport, or null.</param>
|
||||
/// <returns>A short description.</returns>
|
||||
public static string Describe(AbLegacyTransport? configured) =>
|
||||
configured is null ? $"{Resolve(configured)} (default)" : $"{configured} (configured)";
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using libplctag;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>The ONLY place libplctag's <see cref="Status"/> meets <see cref="AbStatus"/> in AbLegacy.</summary>
|
||||
internal static class LibplctagLegacyStatusTranslation
|
||||
{
|
||||
/// <summary>Translates a libplctag status into the transport-neutral <see cref="AbStatus"/>.</summary>
|
||||
/// <param name="s">The libplctag status.</param>
|
||||
/// <returns>The corresponding <see cref="AbStatus"/>.</returns>
|
||||
public static AbStatus ToAbStatus(Status s) => s switch
|
||||
{
|
||||
Status.Ok => AbStatus.Ok,
|
||||
Status.Pending => AbStatus.Pending,
|
||||
Status.ErrorTimeout => AbStatus.Timeout,
|
||||
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.NotFound,
|
||||
Status.ErrorNotAllowed => AbStatus.NotAllowed,
|
||||
Status.ErrorOutOfBounds => AbStatus.OutOfBounds,
|
||||
Status.ErrorTooLarge => AbStatus.TooLarge,
|
||||
Status.ErrorTooSmall => AbStatus.TooSmall,
|
||||
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbStatus.Unsupported,
|
||||
Status.ErrorPartial => AbStatus.Partial,
|
||||
Status.ErrorAbort => AbStatus.Abort,
|
||||
Status.ErrorBadReply => AbStatus.BadReply,
|
||||
_ => AbStatus.BadConnection,
|
||||
};
|
||||
|
||||
/// <summary>libplctag builds the exception from <c>Status.ToString()</c>, so the message is the enum name.</summary>
|
||||
/// <param name="ex">The libplctag exception.</param>
|
||||
/// <returns>The corresponding <see cref="AbStatus"/>.</returns>
|
||||
public static AbStatus ToAbStatus(LibPlcTagException ex) =>
|
||||
Enum.TryParse<Status>((ex.Message ?? "").Trim(), ignoreCase: true, out var s) && Enum.IsDefined(s)
|
||||
? ToAbStatus(s)
|
||||
: AbStatus.BadConnection;
|
||||
|
||||
/// <summary>Runs <paramref name="op"/>, rethrowing libplctag failures as <see cref="AbTransportException"/>.</summary>
|
||||
/// <param name="op">The libplctag operation.</param>
|
||||
/// <returns>A task completing with the operation.</returns>
|
||||
public static async Task Wrap(Func<Task> op)
|
||||
{
|
||||
try { await op().ConfigureAwait(false); }
|
||||
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using libplctag;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
@@ -12,35 +13,86 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
{
|
||||
private readonly Tag _tag;
|
||||
|
||||
/// <summary>
|
||||
/// The status the native tag held when a failed initialisation destroyed it (see
|
||||
/// <see cref="LibplctagOrphanRelease"/>); null while the wrapper's own handle is authoritative.
|
||||
/// </summary>
|
||||
private Status? _releasedStatus;
|
||||
|
||||
/// <summary>The native handle a failed initialisation destroyed (0 if none); the wrapper's own field is cleared.</summary>
|
||||
private int _releasedHandle;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="LibplctagLegacyTagRuntime"/> class.</summary>
|
||||
/// <param name="p">The parameters for tag creation.</param>
|
||||
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
|
||||
{
|
||||
_tag = new Tag
|
||||
_tag = BuildTag(p);
|
||||
}
|
||||
|
||||
/// <summary>Builds the (uninitialized) libplctag tag for <paramref name="p"/>.</summary>
|
||||
/// <param name="p">The tag creation parameters.</param>
|
||||
internal static Tag BuildTag(AbLegacyTagCreateParams p) =>
|
||||
new Tag
|
||||
{
|
||||
// host:port — see AbLegacyTagCreateParams/AbCipTagCreateParams.LibplctagGateway.
|
||||
// Passing p.Gateway alone drops the authored port onto libplctag's 44818 default (#534).
|
||||
Gateway = p.LibplctagGateway,
|
||||
Path = p.CipPath,
|
||||
Path = LibplctagPath.Normalize(p.CipPath),
|
||||
PlcType = MapPlcType(p.LibplctagPlcAttribute),
|
||||
Protocol = Protocol.ab_eip, // PCCC-over-EIP; libplctag routes via the PlcType-specific PCCC layer
|
||||
Name = p.TagName,
|
||||
Timeout = p.Timeout,
|
||||
ElementCount = p.ElementCount > 1 ? p.ElementCount : null,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(CancellationToken cancellationToken) =>
|
||||
RunReleasingOrphanAsync(() => _tag.InitializeAsync(cancellationToken));
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>libplctag initialises an uninitialised tag on its first read, so this path can orphan one too.</remarks>
|
||||
public Task ReadAsync(CancellationToken cancellationToken) =>
|
||||
RunReleasingOrphanAsync(() => _tag.ReadAsync(cancellationToken));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(CancellationToken cancellationToken) =>
|
||||
RunReleasingOrphanAsync(() => _tag.WriteAsync(cancellationToken));
|
||||
|
||||
/// <summary>
|
||||
/// Runs a libplctag operation; when it fails and leaves the tag uninitialised, destroys the native tag it
|
||||
/// created before the failure propagates, so its callback can never fire into a collected delegate (T21 press
|
||||
/// pass 1 crash). The status that tag held is kept for <see cref="GetStatus"/>.
|
||||
/// </summary>
|
||||
private async Task RunReleasingOrphanAsync(Func<Task> op)
|
||||
{
|
||||
try
|
||||
{
|
||||
await LibplctagLegacyStatusTranslation.Wrap(op).ConfigureAwait(false);
|
||||
_releasedStatus = null;
|
||||
_releasedHandle = 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
var handle = LibplctagOrphanRelease.HandleOf(_tag);
|
||||
if (LibplctagOrphanRelease.ReleaseIfOrphaned(_tag) is { } status)
|
||||
{
|
||||
_releasedStatus = status;
|
||||
_releasedHandle = handle;
|
||||
}
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
|
||||
/// <summary>The native libplctag handle this runtime's tag holds, or the one a failed initialisation destroyed
|
||||
/// (0 if none was created). Tests only.</summary>
|
||||
internal int NativeHandle => _releasedHandle != 0 ? _releasedHandle : LibplctagOrphanRelease.HandleOf(_tag);
|
||||
|
||||
/// <summary>The handle the libplctag wrapper itself still holds (0 once a release cleared it). Tests only.</summary>
|
||||
internal int WrapperHandle => LibplctagOrphanRelease.HandleOf(_tag);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ReadAsync(CancellationToken cancellationToken) => _tag.ReadAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(CancellationToken cancellationToken) => _tag.WriteAsync(cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public int GetStatus() => (int)_tag.GetStatus();
|
||||
public int GetStatus() =>
|
||||
(int)LibplctagLegacyStatusTranslation.ToAbStatus(_releasedStatus ?? _tag.GetStatus());
|
||||
|
||||
/// <inheritdoc />
|
||||
public object? DecodeValue(AbLegacyDataType type, int? bitIndex) => type switch
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Reflection;
|
||||
using libplctag;
|
||||
using libplctag.NativeImport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Releases the native tag a FAILED libplctag initialisation leaves behind (T21 press pass 1 crash).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>libplctag.NET 1.5.2's <c>NativeTagWrapper.InitializeAsync</c> calls <c>plc_tag_create_ex</c>, which returns a
|
||||
/// native handle with the wrapper's <c>callback_func_ex</c> delegate registered, and only then waits for the
|
||||
/// <c>Created</c> event. When that event carries an error (a tag the controller does not have, a refused path, ...)
|
||||
/// it throws WITHOUT setting <c>_isInitialized</c>, and <c>Dispose()</c> unregisters and destroys only
|
||||
/// <c>if (_isInitialized)</c>. So the native tag outlives its wrapper with the callback still registered; once the
|
||||
/// wrapper is collected the delegate's thunk is freed, and the next event libplctag raises on that tag — at the
|
||||
/// latest the <c>DESTROYED</c> event from <c>plc_tag_shutdown</c>, which the native library runs from
|
||||
/// <c>atexit</c> — kills the process with "A callback was made on a garbage collected delegate of type
|
||||
/// 'libplctag.NativeImport!libplctag.NativeImport.plctag+callback_func_ex::Invoke'". A timed-out initialisation
|
||||
/// unregisters the callback but never destroys the handle either, so that native tag keeps reconnecting in the
|
||||
/// background for the life of the process.</para>
|
||||
/// <para>The wrapper exposes no handle, so it is read by reflection from the two private fields of the pinned
|
||||
/// 1.5.2 wrapper (<c>Tag._tag</c>, <c>NativeTagWrapper.nativeTagHandle</c>). If a future wrapper renames them the
|
||||
/// release cannot run; the tag is then rooted for the life of the process instead, which keeps its delegate
|
||||
/// alive. That is no crash but an UNBOUNDED leak — one native tag (holding its libplctag session open) per failed
|
||||
/// create, and the data path re-creates a NotFound tag on every read — so <c>LibplctagLegacyOrphanReleaseTests</c> fails, so the bump is noticed.</para>
|
||||
/// </remarks>
|
||||
internal static class LibplctagOrphanRelease
|
||||
{
|
||||
private const BindingFlags Private = BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
private static readonly FieldInfo? WrapperField = typeof(Tag).GetField("_tag", Private);
|
||||
// Final review M2: the field must also still be an int. A wrapper that retyped it (IntPtr, long) would make
|
||||
// the cast in HandleOf throw; treating it as unreadable instead degrades to the rooting fallback ("keep
|
||||
// alive"), which the pin test reports.
|
||||
private static readonly FieldInfo? HandleField =
|
||||
WrapperField?.FieldType.GetField("nativeTagHandle", Private) is { } f && f.FieldType == typeof(int) ? f : null;
|
||||
|
||||
/// <summary>Tags whose handle could not be read: kept reachable so their callback delegate is never collected.</summary>
|
||||
private static readonly ConcurrentBag<Tag> Rooted = [];
|
||||
|
||||
private static int _released;
|
||||
|
||||
/// <summary>True when the pinned wrapper's private fields were found, so a failed tag can be destroyed.</summary>
|
||||
internal static bool CanReadHandles => HandleField is not null && !ForceRootingForTest;
|
||||
|
||||
/// <summary>Test seam (review r1 M1): take the rooting fallback as if the wrapper's fields had been renamed.</summary>
|
||||
internal static bool ForceRootingForTest { get; set; }
|
||||
|
||||
/// <summary>Orphaned native tags destroyed so far (process-wide; diagnostics and tests).</summary>
|
||||
internal static int Released => Volatile.Read(ref _released);
|
||||
|
||||
/// <summary>Orphaned tags rooted for the process lifetime because their handle could not be read.</summary>
|
||||
internal static int RootedCount => Rooted.Count;
|
||||
|
||||
/// <summary>The native handle the wrapper holds (0 when none was ever created, or unreadable). Never throws:
|
||||
/// callers run it inside a catch block, where an exception would replace the failure being handled.</summary>
|
||||
internal static int HandleOf(Tag tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
return HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper
|
||||
&& HandleField.GetValue(wrapper) is int handle ? handle : 0;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears the wrapper's handle after a destroy, so a repeat release is a no-op (review r1 M2).</summary>
|
||||
private static void ClearHandle(Tag tag)
|
||||
{
|
||||
if (HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper) HandleField.SetValue(wrapper, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a libplctag operation that may initialise <paramref name="tag"/> (InitializeAsync, or a first
|
||||
/// Read/Write, which initialise on demand). If it fails and leaves the tag uninitialised, the native tag it
|
||||
/// created is unregistered and destroyed before the failure propagates.
|
||||
/// </summary>
|
||||
internal static async Task RunAsync(Tag tag, Func<Task> op)
|
||||
{
|
||||
try
|
||||
{
|
||||
await op().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
ReleaseIfOrphaned(tag);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="RunAsync(Tag, Func{Task})"/>
|
||||
internal static async Task<T> RunAsync<T>(Tag tag, Func<Task<T>> op)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await op().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
ReleaseIfOrphaned(tag);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Destroys the native tag behind an UNINITIALISED wrapper, unregistering its callback first. No-op for an
|
||||
/// initialised tag (its own <c>Dispose</c> handles it) or one that never got a handle. Never throws.
|
||||
/// </summary>
|
||||
/// <returns>The status the native tag held just before it was destroyed (what <c>GetStatus()</c> would have
|
||||
/// reported), or null when nothing was destroyed.</returns>
|
||||
internal static Status? ReleaseIfOrphaned(Tag tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (tag.IsInitialized) return null;
|
||||
if (!CanReadHandles)
|
||||
{
|
||||
Rooted.Add(tag);
|
||||
return null;
|
||||
}
|
||||
var handle = HandleOf(tag);
|
||||
if (handle <= 0) return null; // plc_tag_create_ex refused synchronously: nothing native exists
|
||||
var status = (Status)plctag.plc_tag_status(handle);
|
||||
plctag.plc_tag_unregister_callback(handle);
|
||||
plctag.plc_tag_destroy(handle);
|
||||
ClearHandle(tag);
|
||||
Interlocked.Increment(ref _released);
|
||||
return status;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A release must never mask the initialisation failure it follows. Root the tag so that, whatever
|
||||
// state the native side is in, its callback delegate stays alive.
|
||||
Rooted.Add(tag);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a parsed CIP route for libplctag's <c>Path</c> attribute. libplctag rejects an
|
||||
/// empty-string path with <c>ErrorBadData</c>; "no route" (Micro800, unrouted MicroLogix) must be
|
||||
/// expressed by omitting the attribute, i.e. <see langword="null"/>.
|
||||
/// </summary>
|
||||
internal static class LibplctagPath
|
||||
{
|
||||
/// <summary>Returns <see langword="null"/> for a null, empty or whitespace path; otherwise the path unchanged.</summary>
|
||||
/// <param name="path">The parsed CIP path.</param>
|
||||
public static string? Normalize(string? path) => string.IsNullOrWhiteSpace(path) ? null : path;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>Creates <see cref="NativeAbLegacyTagRuntime"/> instances over a <see cref="DeviceSessionPool"/>
|
||||
/// (the process-wide shared pool by default).</summary>
|
||||
internal sealed class NativeAbLegacyTagFactory(DeviceSessionPool? pool = null) : IAbLegacyTagFactory
|
||||
{
|
||||
private readonly DeviceSessionPool _pool = pool ?? DeviceSessionPool.Shared;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams createParams) =>
|
||||
new NativeAbLegacyTagRuntime(createParams, _pool);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
using System.Buffers.Binary;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Native <see cref="IAbLegacyTagRuntime"/>: PCCC data-table reads and writes carried in Execute PCCC
|
||||
/// through the device's shared <see cref="RequestScheduler"/> (one per device connection, never per tag).
|
||||
/// Decoded values, exceptions and statuses match <see cref="LibplctagLegacyTagRuntime"/>.
|
||||
/// </summary>
|
||||
internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
{
|
||||
private const int DefaultPort = 44818;
|
||||
private const int StringDataBytes = 82;
|
||||
private const int StringElementBytes = 84;
|
||||
|
||||
// One process-wide TNS counter: no two in-flight requests (on any connection) share a TNS, so a stale
|
||||
// reply belonging to another tag fails the TNS echo check instead of matching by accident.
|
||||
private static int s_tns;
|
||||
|
||||
private readonly string _plc;
|
||||
private readonly RequestScheduler _scheduler;
|
||||
private readonly ConnectionLease? _lease;
|
||||
private readonly byte[] _requestorId;
|
||||
private readonly ushort _vendor;
|
||||
private readonly uint _serial;
|
||||
private readonly PcccAddress? _address;
|
||||
private readonly Exception? _addressError;
|
||||
private readonly int _elementSize;
|
||||
private readonly int _count;
|
||||
private readonly byte[] _buffer;
|
||||
private int _status;
|
||||
|
||||
/// <summary>Creates a runtime that leases the shared connection for its device from <paramref name="pool"/>.</summary>
|
||||
public NativeAbLegacyTagRuntime(AbLegacyTagCreateParams p, DeviceSessionPool pool)
|
||||
: this(p, pool, OptionsFor(p))
|
||||
{
|
||||
}
|
||||
|
||||
private NativeAbLegacyTagRuntime(AbLegacyTagCreateParams p, DeviceSessionPool pool, ConnectionOptions options)
|
||||
: this(p, pool.Acquire(KeyFor(p), options))
|
||||
{
|
||||
}
|
||||
|
||||
// The PCCC requestor id is libplctag's captured identity, not the connection's CIP originator: ab_server
|
||||
// replies with that fixed id whatever was sent, and real controllers echo it (see PcccServices.RequestorVendor).
|
||||
private NativeAbLegacyTagRuntime(AbLegacyTagCreateParams p, ConnectionLease lease)
|
||||
: this(p, lease.Scheduler, PcccServices.RequestorVendor, PcccServices.RequestorSerial)
|
||||
{
|
||||
_lease = lease;
|
||||
}
|
||||
|
||||
/// <summary>Test seam: runs over an explicit scheduler with an explicit requestor identity.</summary>
|
||||
internal NativeAbLegacyTagRuntime(AbLegacyTagCreateParams p, RequestScheduler scheduler, ushort vendor, uint serial)
|
||||
{
|
||||
_plc = p.LibplctagPlcAttribute;
|
||||
_scheduler = scheduler;
|
||||
_vendor = vendor;
|
||||
_serial = serial;
|
||||
_requestorId = new byte[7];
|
||||
_requestorId[0] = 7;
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(_requestorId.AsSpan(1), vendor);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(_requestorId.AsSpan(3), serial);
|
||||
_count = Math.Max(1, p.ElementCount);
|
||||
|
||||
try
|
||||
{
|
||||
_address = ToPcccAddress(p.TagName);
|
||||
}
|
||||
catch (AbTransportException ex)
|
||||
{
|
||||
_addressError = ex;
|
||||
}
|
||||
|
||||
_elementSize = _address is null ? 2 : ElementSize(_address);
|
||||
_buffer = new byte[_elementSize * _count];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routing per PLC family, from the golden captures (<c>Captures/README.md</c> "Routing observed"):
|
||||
/// slc500 / micrologix / plc5 send Execute PCCC bare in SendRRData with no route even when a
|
||||
/// <c>1,0</c> path is configured, so they are <see cref="CipRouting.Direct"/>. logixpccc sends an
|
||||
/// Unconnected Send (0x52) with route <c>01 00</c> (README line ~129, scratch capture: ab_server
|
||||
/// rejected it, so the shape is read from the client side), so it is <see cref="CipRouting.UnconnectedSend"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// logixpccc framing is UNVERIFIED and differs from libplctag: libplctag uses PLC-5 addressing with
|
||||
/// 0x68/0x67 for LGX_PCCC, while this runtime sends SLC typed 0xA2/0xAA (no capture exists either way).
|
||||
/// plc5 is likewise unverified beyond N-file reads. Neither family should default to Native until proven.
|
||||
/// </remarks>
|
||||
internal static CipRouting RoutingFor(string plcAttribute) => plcAttribute switch
|
||||
{
|
||||
"logixpccc" => CipRouting.UnconnectedSend,
|
||||
_ => CipRouting.Direct,
|
||||
};
|
||||
|
||||
internal static DeviceKey KeyFor(AbLegacyTagCreateParams p) =>
|
||||
new(p.Gateway, p.Port > 0 ? p.Port : DefaultPort, p.CipPath, p.LibplctagPlcAttribute);
|
||||
|
||||
internal static ConnectionOptions OptionsFor(AbLegacyTagCreateParams p)
|
||||
{
|
||||
var timeout = p.Timeout > TimeSpan.Zero && p.Timeout != Timeout.InfiniteTimeSpan
|
||||
? p.Timeout
|
||||
: TimeSpan.FromSeconds(5);
|
||||
return new ConnectionOptions
|
||||
{
|
||||
Routing = RoutingFor(p.LibplctagPlcAttribute),
|
||||
ConnectionSize = 504,
|
||||
AllowPacking = false, // PCCC requests are never MSP-packed
|
||||
Timeout = timeout,
|
||||
};
|
||||
}
|
||||
|
||||
private static PcccAddress ToPcccAddress(string tagName)
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse(tagName)
|
||||
?? throw new AbTransportException(AbStatus.Unsupported, $"'{tagName}' is not a valid PCCC address.");
|
||||
return a.SubElement is not null
|
||||
? PcccAddress.WithSubElementName(a.FileLetter, a.FileNumber, a.WordNumber, a.SubElement)
|
||||
: new PcccAddress(a.FileLetter, a.FileNumber, a.WordNumber, null, a.BitIndex);
|
||||
}
|
||||
|
||||
private static int ElementSize(PcccAddress a) => a.FileLetter switch
|
||||
{
|
||||
"F" or "L" => 4,
|
||||
"ST" => StringElementBytes,
|
||||
"T" or "C" or "R" => a.SubElement is null ? 6 : 2,
|
||||
_ => 2,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitializeAsync(CancellationToken cancellationToken) =>
|
||||
// libplctag resolves the tag on create and reports NotFound for a missing address; one read does the same.
|
||||
RunAsync(() => ReadCoreAsync(cancellationToken));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ReadAsync(CancellationToken cancellationToken) => RunAsync(() => ReadCoreAsync(cancellationToken));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WriteAsync(CancellationToken cancellationToken) => RunAsync(() => WriteCoreAsync(cancellationToken));
|
||||
|
||||
/// <inheritdoc />
|
||||
public int GetStatus() => Volatile.Read(ref _status);
|
||||
|
||||
private async Task RunAsync(Func<Task> op)
|
||||
{
|
||||
try
|
||||
{
|
||||
await op().ConfigureAwait(false);
|
||||
Volatile.Write(ref _status, (int)AbStatus.Ok);
|
||||
}
|
||||
catch (AbTransportException ex)
|
||||
{
|
||||
Volatile.Write(ref _status, (int)ex.Status);
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
Volatile.Write(ref _status, (int)AbStatus.BadConnection);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private (int ChunkElements, PcccAddress Address) Plan()
|
||||
{
|
||||
var address = _address ?? throw _addressError!;
|
||||
if (_count > 1 && address.SubElement is not null)
|
||||
throw new AbTransportException(AbStatus.Unsupported, "Arrays of a structured-element sub-element are not supported.");
|
||||
return (Math.Max(1, PcccServices.MaxDataBytes / _elementSize), address);
|
||||
}
|
||||
|
||||
private async Task<ReadOnlyMemory<byte>> ExchangeAsync(Func<ushort, byte[]> pccc, CancellationToken ct)
|
||||
{
|
||||
var tns = unchecked((ushort)Interlocked.Increment(ref s_tns));
|
||||
var request = PcccServices.ExecutePccc(_vendor, _serial, pccc(tns));
|
||||
var reply = await _scheduler.SendAsync(request, ct).ConfigureAwait(false);
|
||||
return PcccServices.ParseReply(reply, tns, _requestorId);
|
||||
}
|
||||
|
||||
private async Task ReadCoreAsync(CancellationToken ct)
|
||||
{
|
||||
var (chunk, address) = Plan();
|
||||
var plc5 = _plc == "plc5";
|
||||
if (plc5 && (!PcccServices.Plc5Verified || address.FileLetter != "N"))
|
||||
throw new AbTransportException(AbStatus.Unsupported, "PLC-5 reads are only verified for N files.");
|
||||
|
||||
var staged = new byte[_buffer.Length];
|
||||
for (var done = 0; done < _count; done += chunk)
|
||||
{
|
||||
var n = Math.Min(chunk, _count - done);
|
||||
var bytes = n * _elementSize;
|
||||
var at = address with { Element = address.Element + done };
|
||||
var data = await ExchangeAsync(
|
||||
tns => plc5
|
||||
? PcccServices.Plc5RangeRead(tns, at, (ushort)(bytes / 2), bytes)
|
||||
: PcccServices.SlcTypedRead(tns, at, bytes),
|
||||
ct).ConfigureAwait(false);
|
||||
if (data.Length < bytes)
|
||||
throw new AbTransportException(AbStatus.BadReply, $"PCCC read returned {data.Length} of {bytes} bytes.");
|
||||
if (data.Length > bytes)
|
||||
throw new AbTransportException(AbStatus.TooLarge, $"PCCC read returned {data.Length} bytes, more than the {bytes} requested.");
|
||||
data.Span[..bytes].CopyTo(staged.AsSpan(done * _elementSize));
|
||||
}
|
||||
|
||||
// Only a fully successful read replaces the buffer: a failed chunk never leaves a half-updated value.
|
||||
staged.CopyTo(_buffer, 0);
|
||||
}
|
||||
|
||||
private async Task WriteCoreAsync(CancellationToken ct)
|
||||
{
|
||||
if (_plc == "plc5")
|
||||
throw new AbTransportException(AbStatus.Unsupported, "PLC-5 writes are not capture-verified.");
|
||||
// libplctag does a masked bit write (0xAB) here; there is no capture of it, and a read-modify-write
|
||||
// would race the PLC updating TT/DN. Writing the word instead would clobber EN/TT/DN, so refuse
|
||||
// before anything reaches the wire.
|
||||
if (StatusBit is not null)
|
||||
throw new AbTransportException(AbStatus.Unsupported,
|
||||
"Writes to a status-bit sub-element (e.g. T4:0.DN) are not supported by the native transport.");
|
||||
var (chunk, address) = Plan();
|
||||
for (var done = 0; done < _count; done += chunk)
|
||||
{
|
||||
var n = Math.Min(chunk, _count - done);
|
||||
var bytes = n * _elementSize;
|
||||
var at = address with { Element = address.Element + done };
|
||||
var slice = _buffer.AsMemory(done * _elementSize, bytes);
|
||||
// A later chunk failing throws: a partial write is reported as failed, never Good.
|
||||
await ExchangeAsync(tns => PcccServices.SlcTypedWrite(tns, at, slice.Span), ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private ReadOnlySpan<byte> Slice(int offset, int length)
|
||||
{
|
||||
if (offset < 0 || offset + length > _buffer.Length)
|
||||
throw new AbTransportException(AbStatus.OutOfBounds, $"Buffer access {offset}+{length} exceeds {_buffer.Length} bytes.");
|
||||
return _buffer.AsSpan(offset, length);
|
||||
}
|
||||
|
||||
private Span<byte> SliceW(int offset, int length)
|
||||
{
|
||||
if (offset < 0 || offset + length > _buffer.Length)
|
||||
throw new AbTransportException(AbStatus.OutOfBounds, $"Buffer access {offset}+{length} exceeds {_buffer.Length} bytes.");
|
||||
return _buffer.AsSpan(offset, length);
|
||||
}
|
||||
|
||||
private short GetInt16(int offset) => BinaryPrimitives.ReadInt16LittleEndian(Slice(offset, 2));
|
||||
private int GetInt32(int offset) => BinaryPrimitives.ReadInt32LittleEndian(Slice(offset, 4));
|
||||
private float GetFloat32(int offset) => BinaryPrimitives.ReadSingleLittleEndian(Slice(offset, 4));
|
||||
|
||||
/// <summary>The bit of a status-bit sub-element (T4:0.DN, C5:0.CU, R6:0.EN...), else null.</summary>
|
||||
private int? StatusBit => _address is { SubElement: not null, Bit: int b } ? b : null;
|
||||
|
||||
private bool GetBit(int bit) => bit < 16
|
||||
? (((ushort)GetInt16(0) >> bit) & 1) != 0
|
||||
: (((uint)GetInt32(0) >> bit) & 1) != 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
public object? DecodeValue(AbLegacyDataType type, int? bitIndex)
|
||||
{
|
||||
// A status-bit sub-element is a BIT tag in libplctag: every scalar getter returns that bit (0/1).
|
||||
if (StatusBit is int sb && type != AbLegacyDataType.String)
|
||||
{
|
||||
var v = GetBit(sb);
|
||||
return type switch
|
||||
{
|
||||
AbLegacyDataType.Bit => v,
|
||||
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long
|
||||
or AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
||||
or AbLegacyDataType.ControlElement => v ? 1 : 0,
|
||||
AbLegacyDataType.Float => v ? 1f : 0f,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
return type switch
|
||||
{
|
||||
AbLegacyDataType.Bit => bitIndex is int bit ? GetBit(bit) : (GetInt16(0) & 1) != 0,
|
||||
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt => (int)GetInt16(0),
|
||||
AbLegacyDataType.Long => GetInt32(0),
|
||||
AbLegacyDataType.Float => GetFloat32(0),
|
||||
AbLegacyDataType.String => DecodeString(),
|
||||
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
||||
or AbLegacyDataType.ControlElement => GetInt32(0),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
// SLC ST element: u16 LEN, then 82 data bytes with each byte pair swapped.
|
||||
private string DecodeString()
|
||||
{
|
||||
var len = Math.Clamp((int)(ushort)GetInt16(0), 0, StringDataBytes);
|
||||
var chars = new char[len];
|
||||
for (var i = 0; i < len; i++) chars[i] = (char)Slice(2 + (i ^ 1), 1)[0];
|
||||
return new string(chars);
|
||||
}
|
||||
|
||||
private void SetString(string s)
|
||||
{
|
||||
if (s.Length > StringDataBytes)
|
||||
throw new AbTransportException(AbStatus.TooLarge, $"String of {s.Length} chars exceeds {StringDataBytes}.");
|
||||
var dest = SliceW(0, StringElementBytes);
|
||||
dest.Clear();
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(dest, (ushort)s.Length);
|
||||
for (var i = 0; i < s.Length; i++) dest[2 + (i ^ 1)] = (byte)s[i];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public object? DecodeArray(AbLegacyDataType type, int count)
|
||||
{
|
||||
// Same layout assumptions as LibplctagLegacyTagRuntime.DecodeArray: element i at i * elementSize
|
||||
// (2 for N/A, 4 for L/F), B files bit-packed (element i is bit i). String and structured arrays unsupported.
|
||||
switch (type)
|
||||
{
|
||||
case AbLegacyDataType.Int:
|
||||
case AbLegacyDataType.AnalogInt:
|
||||
{
|
||||
var arr = new short[count];
|
||||
for (var i = 0; i < count; i++) arr[i] = GetInt16(i * 2);
|
||||
return arr;
|
||||
}
|
||||
case AbLegacyDataType.Long:
|
||||
{
|
||||
var arr = new int[count];
|
||||
for (var i = 0; i < count; i++) arr[i] = GetInt32(i * 4);
|
||||
return arr;
|
||||
}
|
||||
case AbLegacyDataType.Float:
|
||||
{
|
||||
var arr = new float[count];
|
||||
for (var i = 0; i < count; i++) arr[i] = GetFloat32(i * 4);
|
||||
return arr;
|
||||
}
|
||||
case AbLegacyDataType.Bit:
|
||||
{
|
||||
var arr = new bool[count];
|
||||
for (var i = 0; i < count; i++) arr[i] = ((Slice(i / 8, 1)[0] >> (i % 8)) & 1) != 0;
|
||||
return arr;
|
||||
}
|
||||
default:
|
||||
throw new NotSupportedException(
|
||||
$"AbLegacyDataType {type} is not supported as an array element.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case AbLegacyDataType.Bit:
|
||||
if (bitIndex is int)
|
||||
throw new NotSupportedException(
|
||||
"Bit-with-bitIndex writes must go through AbLegacyDriver.WriteBitInWordAsync.");
|
||||
BinaryPrimitives.WriteInt16LittleEndian(SliceW(0, 2), Convert.ToBoolean(value) ? (short)1 : (short)0);
|
||||
break;
|
||||
case AbLegacyDataType.Int:
|
||||
case AbLegacyDataType.AnalogInt:
|
||||
BinaryPrimitives.WriteInt16LittleEndian(SliceW(0, 2), Convert.ToInt16(value));
|
||||
break;
|
||||
case AbLegacyDataType.Long:
|
||||
BinaryPrimitives.WriteInt32LittleEndian(SliceW(0, 4), Convert.ToInt32(value));
|
||||
break;
|
||||
case AbLegacyDataType.Float:
|
||||
BinaryPrimitives.WriteSingleLittleEndian(SliceW(0, 4), Convert.ToSingle(value));
|
||||
break;
|
||||
case AbLegacyDataType.String:
|
||||
SetString(Convert.ToString(value) ?? string.Empty);
|
||||
break;
|
||||
case AbLegacyDataType.TimerElement:
|
||||
case AbLegacyDataType.CounterElement:
|
||||
case AbLegacyDataType.ControlElement:
|
||||
BinaryPrimitives.WriteInt32LittleEndian(SliceW(0, 4), Convert.ToInt32(value));
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"AbLegacyDataType {type} not writable.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Releases the lease on the shared device connection (the last release closes it).</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_lease is null) return;
|
||||
// Closing does I/O (Forward Close / UnRegisterSession); never block the caller on it.
|
||||
_ = _lease.DisposeAsync().AsTask().ContinueWith(
|
||||
static t => { _ = t.Exception; }, TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
+21
-2
@@ -32,6 +32,15 @@
|
||||
</div>
|
||||
<div class="form-text">Only enable when UDT member declaration order matches controller compiled layout.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="abcipTransport">Transport</label>
|
||||
<InputSelect @bind-Value="_form.Transport" @bind-Value:after="EmitAsync" class="form-select form-select-sm" id="abcipTransport">
|
||||
<option value="">Default (node setting; Native unless pinned)</option>
|
||||
<option value="@AbCipTransport.Native">Native (managed)</option>
|
||||
<option value="@AbCipTransport.Libplctag">libplctag (fallback)</option>
|
||||
</InputSelect>
|
||||
<div class="form-text">Which protocol stack talks to the controller. Default omits the setting so the node-level <code>Drivers:AbCip:DefaultTransport</code> picks (Native unless an operator pinned it); takes effect on the next deploy.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -136,7 +145,11 @@
|
||||
/// Devices array forward (the device form authors the real endpoints; the merge reconstructs the array
|
||||
/// from the device rows at deploy/probe time).</summary>
|
||||
public string GetConfigJson()
|
||||
=> JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
|
||||
=> Serialize(_form.ToOptions(_preservedDevices));
|
||||
|
||||
/// <summary>Serializes options with the form's JSON settings (camelCase, enums as strings).</summary>
|
||||
internal static string Serialize(AbCipDriverOptions options)
|
||||
=> JsonSerializer.Serialize(options, _jsonOpts);
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
@@ -151,7 +164,7 @@
|
||||
await ResilienceConfigChanged.InvokeAsync(r);
|
||||
}
|
||||
|
||||
private static AbCipDriverOptions? TryDeserialize(string json)
|
||||
internal static AbCipDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return JsonSerializer.Deserialize<AbCipDriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
@@ -163,6 +176,10 @@
|
||||
{
|
||||
// Operation
|
||||
public int TimeoutSeconds { get; set; } = 2;
|
||||
// Null = "Default": the transport key is OMITTED on save so the driver resolves it (and the AbLegacy
|
||||
// PLC-5 / LogixPccc exclusion still applies). Never re-emit a concrete default here — that would pin
|
||||
// every edited driver to it.
|
||||
public AbCipTransport? Transport { get; set; }
|
||||
public bool EnableControllerBrowse { get; set; } = false;
|
||||
public bool EnableDeclarationOnlyUdtGrouping { get; set; } = false;
|
||||
|
||||
@@ -182,6 +199,7 @@
|
||||
public static FormModel FromOptions(AbCipDriverOptions o) => new()
|
||||
{
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
Transport = o.Transport,
|
||||
EnableControllerBrowse = o.EnableControllerBrowse,
|
||||
EnableDeclarationOnlyUdtGrouping = o.EnableDeclarationOnlyUdtGrouping,
|
||||
EnableAlarmProjection = o.EnableAlarmProjection,
|
||||
@@ -205,6 +223,7 @@
|
||||
ProbeTagPath = string.IsNullOrWhiteSpace(ProbeTagPath) ? null : ProbeTagPath,
|
||||
},
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
Transport = Transport,
|
||||
EnableControllerBrowse = EnableControllerBrowse,
|
||||
EnableAlarmProjection = EnableAlarmProjection,
|
||||
AlarmPollInterval = TimeSpan.FromSeconds(AlarmPollIntervalSeconds),
|
||||
|
||||
+21
-2
@@ -19,6 +19,15 @@
|
||||
<InputNumber @bind-Value="_form.TimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
|
||||
<div class="form-text">Default read/write timeout. Default 2 s.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="ablegacyTransport">Transport</label>
|
||||
<InputSelect @bind-Value="_form.Transport" @bind-Value:after="EmitAsync" class="form-select form-select-sm" id="ablegacyTransport">
|
||||
<option value="">Default (libplctag)</option>
|
||||
<option value="@AbLegacyTransport.Native">Native (managed, opt-in)</option>
|
||||
<option value="@AbLegacyTransport.Libplctag">libplctag</option>
|
||||
</InputSelect>
|
||||
<div class="form-text">Which protocol stack talks to the controller. Default omits the setting (libplctag). Native is opt-in: wire-proven only for N-file reads/writes and F8 reads. Takes effect on the next deploy.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -101,7 +110,11 @@
|
||||
/// <summary>Serializes the current channel/protocol config to camelCase JSON, preserving the driver's
|
||||
/// existing Devices array (authored as separate v3 entities).</summary>
|
||||
public string GetConfigJson()
|
||||
=> JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
|
||||
=> Serialize(_form.ToOptions(_preservedDevices));
|
||||
|
||||
/// <summary>Serializes options with the form's JSON settings (camelCase, enums as strings).</summary>
|
||||
internal static string Serialize(AbLegacyDriverOptions options)
|
||||
=> JsonSerializer.Serialize(options, _jsonOpts);
|
||||
|
||||
private async Task EmitAsync()
|
||||
{
|
||||
@@ -116,7 +129,7 @@
|
||||
await ResilienceConfigChanged.InvokeAsync(r);
|
||||
}
|
||||
|
||||
private static AbLegacyDriverOptions? TryDeserialize(string json)
|
||||
internal static AbLegacyDriverOptions? TryDeserialize(string json)
|
||||
{
|
||||
try { return JsonSerializer.Deserialize<AbLegacyDriverOptions>(json, _jsonOpts); }
|
||||
catch { return null; }
|
||||
@@ -128,6 +141,10 @@
|
||||
{
|
||||
// Operation
|
||||
public int TimeoutSeconds { get; set; } = 2;
|
||||
// Null = "Default": the transport key is OMITTED on save so the driver resolves it (and the AbLegacy
|
||||
// PLC-5 / LogixPccc exclusion still applies). Never re-emit a concrete default here — that would pin
|
||||
// every edited driver to it.
|
||||
public AbLegacyTransport? Transport { get; set; }
|
||||
|
||||
// Probe
|
||||
public bool ProbeEnabled { get; set; } = true;
|
||||
@@ -145,6 +162,7 @@
|
||||
public static FormModel FromOptions(AbLegacyDriverOptions o) => new()
|
||||
{
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
Transport = o.Transport,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||
@@ -164,6 +182,7 @@
|
||||
ProbeAddress = string.IsNullOrWhiteSpace(ProbeAddress) ? null : ProbeAddress,
|
||||
},
|
||||
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
|
||||
Transport = Transport,
|
||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Node-level defaults for the AbCip driver (final review I1), bound from <c>Drivers:AbCip</c>. Lets an
|
||||
/// operator pin the transport an AbCip driver with NO <c>transport</c> key runs on — at upgrade time,
|
||||
/// without editing (or being able to edit, #576) every driver config. An explicit per-driver
|
||||
/// <c>transport</c> key still wins. Overridable by environment variable
|
||||
/// <c>Drivers__AbCip__DefaultTransport</c>.
|
||||
/// </summary>
|
||||
public sealed class AbCipDriverDefaultsOptions
|
||||
{
|
||||
/// <summary>The configuration section path.</summary>
|
||||
public const string SectionName = "Drivers:AbCip";
|
||||
|
||||
/// <summary>
|
||||
/// <c>Native</c> (the default) or <c>Libplctag</c>, case-insensitive. Blank/absent = <c>Native</c>.
|
||||
/// Any other value fails host start (<see cref="AbCipDriverDefaultsOptionsValidator"/>).
|
||||
/// </summary>
|
||||
public string? DefaultTransport { get; set; } = nameof(AbCipTransport.Native);
|
||||
|
||||
/// <summary>
|
||||
/// The parsed default: blank resolves to the compiled <see cref="AbCipTransportResolver.Default"/>.
|
||||
/// Throws on an unparseable value — unreachable at runtime because the validator fails host start first.
|
||||
/// </summary>
|
||||
/// <returns>The node-level default transport.</returns>
|
||||
public AbCipTransport ResolveDefaultTransport()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(DefaultTransport)) return AbCipTransportResolver.Default;
|
||||
return AbCipTransportResolver.TryParse(DefaultTransport, out var t)
|
||||
? t
|
||||
: throw new InvalidOperationException(AbCipDriverDefaultsOptionsValidator.Message(DefaultTransport));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fail-fast startup validator for <see cref="AbCipDriverDefaultsOptions"/>.</summary>
|
||||
public sealed class AbCipDriverDefaultsOptionsValidator : OptionsValidatorBase<AbCipDriverDefaultsOptions>
|
||||
{
|
||||
/// <summary>The operator-facing failure text for a bad value.</summary>
|
||||
/// <param name="value">The rejected value.</param>
|
||||
/// <returns>The message.</returns>
|
||||
internal static string Message(string? value) =>
|
||||
$"Drivers:AbCip:DefaultTransport is '{value}' — expected one of " +
|
||||
$"{string.Join(", ", Enum.GetNames<AbCipTransport>())} (case-insensitive). It selects the transport an " +
|
||||
"AbCip driver with no \"transport\" key runs on; set it via appsettings or the environment variable " +
|
||||
"Drivers__AbCip__DefaultTransport.";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Validate(ValidationBuilder builder, AbCipDriverDefaultsOptions options)
|
||||
{
|
||||
builder.RequireThat(
|
||||
string.IsNullOrWhiteSpace(options.DefaultTransport)
|
||||
|| AbCipTransportResolver.TryParse(options.DefaultTransport, out _),
|
||||
Message(options.DefaultTransport));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||
@@ -78,7 +80,11 @@ public static class DriverFactoryBootstrap
|
||||
// whole mechanism exists to prevent, and it would fail as a plant outage rather than as a
|
||||
// startup error.
|
||||
var primaryRoleSource = sp.GetRequiredService<IDriverPrimaryRoleSource>();
|
||||
Register(registry, loggerFactory, secretResolver, primaryRoleSource, scriptRoot);
|
||||
// Final review I2: the native AB transport's process-wide pool logs options conflicts and close
|
||||
// failures; without this it writes to NullLogger in production.
|
||||
ConfigureAbTransportLogging(sp);
|
||||
Register(registry, loggerFactory, secretResolver, primaryRoleSource, ResolveAbCipDefaultTransport(sp),
|
||||
scriptRoot);
|
||||
return registry;
|
||||
});
|
||||
services.AddSingleton<IDriverFactory>(sp =>
|
||||
@@ -140,8 +146,21 @@ public static class DriverFactoryBootstrap
|
||||
public static IServiceCollection AddOtOpcUaDriverProbes(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, ModbusProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, AbCipProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, AbLegacyProbe>());
|
||||
// Factory-built so the probe resolves an absent "transport" key to the SAME node default the driver
|
||||
// factory uses (Drivers:AbCip:DefaultTransport, final review I1) — Test Connect probes the stack the
|
||||
// driver would run. The generic factory overload keeps AbCipProbe as the implementation type, which
|
||||
// TryAddEnumerable de-dups on.
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, AbCipProbe>(
|
||||
sp =>
|
||||
{
|
||||
ConfigureAbTransportLogging(sp); // admin-only nodes reach the pool through the probes alone
|
||||
return new AbCipProbe(tagFactory: null, defaultTransport: ResolveAbCipDefaultTransport(sp));
|
||||
}));
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, AbLegacyProbe>(sp =>
|
||||
{
|
||||
ConfigureAbTransportLogging(sp);
|
||||
return new AbLegacyProbe();
|
||||
}));
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, S7Probe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, TwinCATProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, FocasProbe>());
|
||||
@@ -155,6 +174,29 @@ public static class DriverFactoryBootstrap
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gives the native AB transport's process-wide <c>DeviceSessionPool.Shared</c> the host logger factory
|
||||
/// (final review I2). Called from every DI factory that can put the pool to use on this node (the driver
|
||||
/// registry, and the AbCip/AbLegacy probes for admin-only nodes); idempotent. A container without an
|
||||
/// <see cref="ILoggerFactory"/> leaves the pool on its null logger.
|
||||
/// </summary>
|
||||
/// <param name="sp">The service provider.</param>
|
||||
internal static void ConfigureAbTransportLogging(IServiceProvider sp)
|
||||
{
|
||||
if (sp.GetService<ILoggerFactory>() is { } loggerFactory)
|
||||
Driver.Ab.Transport.DeviceSessionPool.ConfigureSharedLogger(loggerFactory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The node-level AbCip default transport (<c>Drivers:AbCip:DefaultTransport</c>, bound + validated in
|
||||
/// Program.cs). A container without the options bound (test graphs) gets the compiled default.
|
||||
/// </summary>
|
||||
/// <param name="sp">The service provider.</param>
|
||||
/// <returns>The transport an AbCip driver with no <c>transport</c> key runs on.</returns>
|
||||
internal static Driver.AbCip.AbCipTransport ResolveAbCipDefaultTransport(IServiceProvider sp) =>
|
||||
sp.GetService<IOptions<AbCipDriverDefaultsOptions>>()?.Value.ResolveDefaultTransport()
|
||||
?? Driver.AbCip.AbCipTransportResolver.Default;
|
||||
|
||||
/// <summary>
|
||||
/// Invoke every cross-platform driver's <c>Register</c> extension. New driver assemblies
|
||||
/// get added here — one line per type. ShouldStub() in <c>DriverInstanceActor</c> still
|
||||
@@ -166,9 +208,10 @@ public static class DriverFactoryBootstrap
|
||||
ILoggerFactory? loggerFactory,
|
||||
ISecretResolver secretResolver,
|
||||
IDriverPrimaryRoleSource primaryRoleSource,
|
||||
Driver.AbCip.AbCipTransport abCipDefaultTransport,
|
||||
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
|
||||
{
|
||||
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry);
|
||||
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry, loggerFactory, abCipDefaultTransport);
|
||||
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
|
||||
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
|
||||
|
||||
@@ -175,6 +175,13 @@ builder.Services.AddOtOpcUaCluster(builder.Configuration);
|
||||
// the startup check (AddOtOpcUaAuth binds LdapOptions but does not attach a validator).
|
||||
builder.Services.AddValidatedOptions<LdapOptions, LdapOptionsValidator>(builder.Configuration, LdapOptions.SectionName);
|
||||
|
||||
// Node-level AbCip default transport (final review I1): the transport an AbCip driver with NO "transport"
|
||||
// key runs on. Unconditional because BOTH consumers need it — the driver factory (driver role) and the
|
||||
// Test Connect probe (admin role, which must probe the stack the driver would run). A bad value fails host
|
||||
// start here rather than silently falling back.
|
||||
builder.Services.AddValidatedOptions<AbCipDriverDefaultsOptions, AbCipDriverDefaultsOptionsValidator>(
|
||||
builder.Configuration, AbCipDriverDefaultsOptions.SectionName);
|
||||
|
||||
if (hasDriver)
|
||||
{
|
||||
builder.Services.AddOtOpcUaRuntime();
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests;
|
||||
|
||||
/// <summary>The CLI <c>--transport</c> flag reaches <see cref="AbCipDriverOptions.Transport"/>.</summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipTransportOptionTests
|
||||
{
|
||||
[Command("capture-transport")]
|
||||
private sealed class CapturingCommand : AbCipCommandBase
|
||||
{
|
||||
public static AbCipTransport? Captured;
|
||||
public static bool Ran;
|
||||
|
||||
public override ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
Ran = true;
|
||||
Captured = BuildOptions([]).Transport;
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With no flag the CLI leaves the option null ("use the default"), and the driver's resolver makes
|
||||
/// that Native — the same path an absent <c>transport</c> key takes in a deployed config.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Transport_defaults_to_the_resolver_default_native()
|
||||
{
|
||||
CapturingCommand.Captured = null;
|
||||
CapturingCommand.Ran = false;
|
||||
using var console = new FakeInMemoryConsole();
|
||||
var app = new CliApplicationBuilder().AddCommand(typeof(CapturingCommand)).UseConsole(console).Build();
|
||||
|
||||
var exit = await app.RunAsync(["capture-transport", "--gateway", "ab://1.2.3.4/1,0"]);
|
||||
|
||||
exit.ShouldBe(0, console.ReadErrorString());
|
||||
CapturingCommand.Ran.ShouldBeTrue();
|
||||
CapturingCommand.Captured.ShouldBeNull();
|
||||
AbCipTransportResolver.Resolve(CapturingCommand.Captured).ShouldBe(AbCipTransport.Native);
|
||||
}
|
||||
|
||||
/// <summary>Verifies CliFx parses <c>--transport</c> (case-insensitive) into the options.</summary>
|
||||
[Theory]
|
||||
[InlineData("native", AbCipTransport.Native)]
|
||||
[InlineData("Native", AbCipTransport.Native)]
|
||||
[InlineData("libplctag", AbCipTransport.Libplctag)]
|
||||
[InlineData("default", null)]
|
||||
public async Task Transport_flag_is_parsed_by_the_cli(string value, AbCipTransport? expected)
|
||||
{
|
||||
CapturingCommand.Ran = false;
|
||||
CapturingCommand.Captured = null;
|
||||
using var console = new FakeInMemoryConsole();
|
||||
var app = new CliApplicationBuilder().AddCommand(typeof(CapturingCommand)).UseConsole(console).Build();
|
||||
|
||||
var exit = await app.RunAsync(["capture-transport", "--gateway", "ab://1.2.3.4/1,0", "--transport", value]);
|
||||
|
||||
exit.ShouldBe(0, console.ReadErrorString());
|
||||
CapturingCommand.Ran.ShouldBeTrue();
|
||||
CapturingCommand.Captured.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using Shouldly;
|
||||
@@ -158,4 +160,82 @@ public sealed class BuildOptionsTests
|
||||
options.Devices.Count.ShouldBe(1);
|
||||
options.Devices[0].PlcFamily.ShouldBe(AbLegacyPlcFamily.Plc5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With no <c>--transport</c> the CLI leaves the option null and the driver's resolver decides: libplctag
|
||||
/// for every family (final review I3; Native is opt-in). An explicit choice is forwarded verbatim and wins.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500, AbLegacyTransport.Libplctag)]
|
||||
[InlineData(AbLegacyPlcFamily.MicroLogix, AbLegacyTransport.Libplctag)]
|
||||
[InlineData(AbLegacyPlcFamily.Plc5, AbLegacyTransport.Libplctag)]
|
||||
[InlineData(AbLegacyPlcFamily.LogixPccc, AbLegacyTransport.Libplctag)]
|
||||
public void BuildOptions_default_transport_goes_through_the_resolver(AbLegacyPlcFamily family, AbLegacyTransport expected)
|
||||
{
|
||||
var options = new TestCommand { Gateway = "ab://192.168.1.20/1,0", PlcType = family }.Build([]);
|
||||
options.Transport.ShouldBeNull();
|
||||
AbLegacyTransportResolver.Resolve(options).ShouldBe(expected);
|
||||
|
||||
var native = new TestCommand
|
||||
{
|
||||
Gateway = "ab://192.168.1.20/1,0", PlcType = family, Transport = AbLegacyCliTransport.Native,
|
||||
}.Build([]);
|
||||
native.Transport.ShouldBe(AbLegacyTransport.Native);
|
||||
AbLegacyTransportResolver.Resolve(native).ShouldBe(AbLegacyTransport.Native);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End to end through CliFx: no <c>--transport</c> resolves to libplctag for every <c>--plc-type</c>
|
||||
/// (final review I3). The slc500 / micrologix rows are the falsification rows — they resolved Native
|
||||
/// before the revert.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("plc5", AbLegacyTransport.Libplctag)]
|
||||
[InlineData("logixpccc", AbLegacyTransport.Libplctag)]
|
||||
[InlineData("slc500", AbLegacyTransport.Libplctag)]
|
||||
[InlineData("micrologix", AbLegacyTransport.Libplctag)]
|
||||
public async Task Plc_type_without_transport_flag_resolves_through_the_resolver(string plcType, AbLegacyTransport expected)
|
||||
{
|
||||
CapturingCommand.Options = null;
|
||||
using var console = new FakeInMemoryConsole();
|
||||
var app = new CliApplicationBuilder().AddCommand(typeof(CapturingCommand)).UseConsole(console).Build();
|
||||
|
||||
var exit = await app.RunAsync(["capture-transport", "--gateway", "ab://1.2.3.4/1,0", "--plc-type", plcType]);
|
||||
|
||||
exit.ShouldBe(0, console.ReadErrorString());
|
||||
CapturingCommand.Options.ShouldNotBeNull();
|
||||
CapturingCommand.Options.Transport.ShouldBeNull();
|
||||
AbLegacyTransportResolver.Resolve(CapturingCommand.Options).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies CliFx really parses <c>--transport</c> (case-insensitive) into the property.</summary>
|
||||
[Theory]
|
||||
[InlineData("native", AbLegacyTransport.Native)]
|
||||
[InlineData("Native", AbLegacyTransport.Native)]
|
||||
[InlineData("libplctag", AbLegacyTransport.Libplctag)]
|
||||
[InlineData("default", null)]
|
||||
public async Task Transport_flag_is_parsed_by_the_cli(string value, AbLegacyTransport? expected)
|
||||
{
|
||||
CapturingCommand.Options = null;
|
||||
using var console = new FakeInMemoryConsole();
|
||||
var app = new CliApplicationBuilder().AddCommand(typeof(CapturingCommand)).UseConsole(console).Build();
|
||||
|
||||
var exit = await app.RunAsync(["capture-transport", "--gateway", "ab://1.2.3.4/1,0", "--transport", value]);
|
||||
|
||||
exit.ShouldBe(0, console.ReadErrorString());
|
||||
CapturingCommand.Options.ShouldNotBeNull();
|
||||
CapturingCommand.Options.Transport.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Command("capture-transport")]
|
||||
private sealed class CapturingCommand : AbLegacyCommandBase
|
||||
{
|
||||
public static AbLegacyDriverOptions? Options;
|
||||
|
||||
public override System.Threading.Tasks.ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
Options = BuildOptions([]);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests;
|
||||
|
||||
public sealed class AbStatusCodesTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData((byte)0x00, AbStatus.Ok)]
|
||||
[InlineData((byte)0x04, AbStatus.NotFound)]
|
||||
[InlineData((byte)0x05, AbStatus.NotFound)]
|
||||
[InlineData((byte)0x16, AbStatus.NotFound)]
|
||||
[InlineData((byte)0x06, AbStatus.Partial)]
|
||||
[InlineData((byte)0x08, AbStatus.Unsupported)]
|
||||
[InlineData((byte)0x0F, AbStatus.NotAllowed)]
|
||||
[InlineData((byte)0x10, AbStatus.DeviceStateConflict)]
|
||||
[InlineData((byte)0x13, AbStatus.TooSmall)]
|
||||
[InlineData((byte)0x15, AbStatus.TooLarge)]
|
||||
[InlineData((byte)0x0B, AbStatus.Ok)]
|
||||
[InlineData((byte)0x0A, AbStatus.OutOfBounds)]
|
||||
[InlineData((byte)0x0C, AbStatus.DeviceStateConflict)]
|
||||
[InlineData((byte)0x0E, AbStatus.NotAllowed)]
|
||||
[InlineData((byte)0x1E, AbStatus.BadReply)]
|
||||
[InlineData((byte)0x01, AbStatus.BadReply)]
|
||||
public void Cip_general_status_maps(byte general, AbStatus expected) =>
|
||||
AbStatusCodes.FromCip(general, ReadOnlySpan<ushort>.Empty).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData((ushort)0x2105, AbStatus.OutOfBounds)]
|
||||
[InlineData((ushort)0x2107, AbStatus.TypeMismatch)]
|
||||
[InlineData((ushort)0x9999, AbStatus.BadReply)]
|
||||
public void General_status_FF_maps_by_extended_status(ushort ext, AbStatus expected) =>
|
||||
AbStatusCodes.FromCip(0xFF, [ext]).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData((byte)0x00, null, AbStatus.Ok)]
|
||||
[InlineData((byte)0x10, null, AbStatus.Unsupported)]
|
||||
[InlineData((byte)0x20, null, AbStatus.NotFound)]
|
||||
[InlineData((byte)0x30, null, AbStatus.NotAllowed)]
|
||||
[InlineData((byte)0x40, null, AbStatus.DeviceStateConflict)]
|
||||
[InlineData((byte)0xF0, (byte)0x06, AbStatus.NotFound)]
|
||||
[InlineData((byte)0xF0, (byte)0x07, AbStatus.NotFound)]
|
||||
[InlineData((byte)0xF0, (byte)0x0A, AbStatus.BadReply)]
|
||||
public void Pccc_status_maps(byte sts, byte? ext, AbStatus expected) =>
|
||||
AbStatusCodes.FromPccc(sts, ext).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public void Ok_is_zero_and_every_error_is_negative()
|
||||
{
|
||||
((int)AbStatus.Ok).ShouldBe(0);
|
||||
foreach (var s in Enum.GetValues<AbStatus>().Where(s => s is not AbStatus.Ok and not AbStatus.Pending))
|
||||
((int)s).ShouldBeLessThan(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
|
||||
|
||||
/// <summary>One EtherNet/IP encapsulation frame from a golden capture.</summary>
|
||||
/// <param name="FromClient"><c>true</c> for a client→server (<c>></c>) frame.</param>
|
||||
/// <param name="Bytes">The full frame: 24-byte encapsulation header plus data.</param>
|
||||
internal sealed record CapturedFrame(bool FromClient, byte[] Bytes);
|
||||
|
||||
/// <summary>
|
||||
/// Loads the golden libplctag ⇄ ab_server captures in <c>Captures/*.hex</c>. See
|
||||
/// <c>Captures/README.md</c> for how each was taken.
|
||||
/// </summary>
|
||||
internal static class CaptureFile
|
||||
{
|
||||
/// <summary>The directory the <c>.hex</c> fixtures are copied to.</summary>
|
||||
public static string Dir => Path.Combine(AppContext.BaseDirectory, "Captures");
|
||||
|
||||
/// <summary>Loads every frame of a capture, in wire order; <c>#</c> lines are skipped.</summary>
|
||||
/// <param name="name">The capture's file name, e.g. <c>logix-read-dint.hex</c>.</param>
|
||||
/// <returns>The frames.</returns>
|
||||
public static IReadOnlyList<CapturedFrame> Load(string name) =>
|
||||
File.ReadAllLines(Path.Combine(Dir, name))
|
||||
.Where(l => l.Length > 2 && l[0] is '>' or '<')
|
||||
.Select(l => new CapturedFrame(l[0] == '>', Convert.FromHexString(l[2..].Trim())))
|
||||
.ToList();
|
||||
|
||||
/// <summary>The CIP payload of a SendRRData / SendUnitData frame (the last CPF item's data,
|
||||
/// minus the 2-byte sequence count for connected data).</summary>
|
||||
/// <param name="f">The frame.</param>
|
||||
/// <returns>The CIP payload, or empty for any other encapsulation command.</returns>
|
||||
public static byte[] CipPayload(CapturedFrame f)
|
||||
{
|
||||
var b = f.Bytes;
|
||||
var cmd = BitConverter.ToUInt16(b, 0); // test-only; fixtures are LE on a LE host
|
||||
if (cmd is not (0x6F or 0x70)) return []; // only SendRRData / SendUnitData carry CPF items
|
||||
var pos = 24 + 4 + 2; // header + interface handle + timeout
|
||||
var itemCount = BitConverter.ToUInt16(b, pos); pos += 2;
|
||||
byte[] last = [];
|
||||
for (var i = 0; i < itemCount; i++)
|
||||
{
|
||||
var type = BitConverter.ToUInt16(b, pos);
|
||||
var len = BitConverter.ToUInt16(b, pos + 2);
|
||||
last = b.AsSpan(pos + 4, len).ToArray();
|
||||
if (type == 0x00B1) last = last[2..]; // strip connected sequence count
|
||||
pos += 4 + len;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
|
||||
|
||||
public sealed class CaptureFileTests
|
||||
{
|
||||
private static readonly string[] Required =
|
||||
[
|
||||
"logix-register-session.hex", "logix-forward-open.hex",
|
||||
"logix-read-dint.hex", "logix-read-dint-array16.hex", "logix-read-string.hex", "logix-read-bool.hex",
|
||||
"logix-write-dint.hex", "logix-write-string.hex", "logix-read-unknown-tag.hex",
|
||||
"logix-tags-listing.hex", "logix-forward-close.hex", "micro800-read-dint.hex",
|
||||
"slc500-read-n7.hex", "slc500-write-n7.hex", "slc500-read-f8.hex",
|
||||
"micrologix-read-n7.hex", "plc5-read-n7.hex",
|
||||
];
|
||||
|
||||
public static TheoryData<string> AllCaptures()
|
||||
{
|
||||
var data = new TheoryData<string>();
|
||||
foreach (var f in Directory.GetFiles(CaptureFile.Dir, "*.hex").Order()) data.Add(Path.GetFileName(f));
|
||||
return data;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_required_capture_is_present() =>
|
||||
Required.Where(n => !File.Exists(Path.Combine(CaptureFile.Dir, n))).ShouldBeEmpty();
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllCaptures))]
|
||||
public void Every_frame_length_field_matches_its_data_length(string name)
|
||||
{
|
||||
var frames = CaptureFile.Load(name);
|
||||
|
||||
frames.ShouldNotBeEmpty();
|
||||
foreach (var f in frames)
|
||||
{
|
||||
f.Bytes.Length.ShouldBeGreaterThanOrEqualTo(24, name);
|
||||
BitConverter.ToUInt16(f.Bytes, 2).ShouldBe((ushort)(f.Bytes.Length - 24), name);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllCaptures))]
|
||||
public void Frames_alternate_request_then_reply(string name)
|
||||
{
|
||||
var frames = CaptureFile.Load(name);
|
||||
|
||||
frames.Count.ShouldBe(frames.Count / 2 * 2, name);
|
||||
for (var i = 0; i < frames.Count; i++) frames[i].FromClient.ShouldBe(i % 2 == 0, $"{name} frame {i}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllCaptures))]
|
||||
public void Every_CPF_item_walk_ends_exactly_at_the_frame_end(string name)
|
||||
{
|
||||
foreach (var f in CaptureFile.Load(name).Where(f => BitConverter.ToUInt16(f.Bytes, 0) is 0x6F or 0x70))
|
||||
{
|
||||
var pos = 24 + 4 + 2;
|
||||
var itemCount = BitConverter.ToUInt16(f.Bytes, pos);
|
||||
pos += 2;
|
||||
for (var i = 0; i < itemCount; i++) pos += 4 + BitConverter.ToUInt16(f.Bytes, pos + 2);
|
||||
|
||||
pos.ShouldBe(f.Bytes.Length, name);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllCaptures))]
|
||||
public void Every_reply_service_is_its_request_service_with_the_reply_bit(string name)
|
||||
{
|
||||
var frames = CaptureFile.Load(name);
|
||||
for (var i = 0; i + 1 < frames.Count; i += 2)
|
||||
{
|
||||
var request = CaptureFile.CipPayload(frames[i]);
|
||||
var reply = CaptureFile.CipPayload(frames[i + 1]);
|
||||
if (request.Length == 0) continue; // RegisterSession: no CIP
|
||||
|
||||
reply[0].ShouldBe((byte)(request[0] | 0x80), $"{name} pair {i / 2}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CipPayload_strips_the_connected_sequence_count()
|
||||
{
|
||||
// Connected Read Tag Fragmented of TestDINT: service 0x52, path size 5 words, symbol 0x91 len 8.
|
||||
var request = CaptureFile.Load("logix-read-dint.hex")[0];
|
||||
|
||||
CaptureFile.CipPayload(request)[..4].ShouldBe(new byte[] { 0x52, 0x05, 0x91, 0x08 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CipPayload_returns_the_unconnected_item_for_SendRRData()
|
||||
{
|
||||
// Large Forward Open request goes out in an unconnected data item (0xB2): service 0x5B.
|
||||
var request = CaptureFile.Load("logix-forward-open.hex")[0];
|
||||
|
||||
CaptureFile.CipPayload(request)[0].ShouldBe((byte)0x5B);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CipPayload_is_empty_for_RegisterSession() =>
|
||||
CaptureFile.CipPayload(CaptureFile.Load("logix-register-session.hex")[0]).ShouldBeEmpty();
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
# Golden frame captures — libplctag ⇄ ab_server
|
||||
|
||||
These `.hex` files are the exact EtherNet/IP frames libplctag exchanged with the `ab_server`
|
||||
simulator. The native AB transport's codecs (plan Tasks 7 and 8) assert **byte parity** against
|
||||
them.
|
||||
|
||||
> ⚠️ They prove parity with **libplctag**, not correctness against a controller. The independent
|
||||
> reference is the real-press captures in
|
||||
> `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectRealControllerCaptureTests.cs`
|
||||
> and `CipTemplateObjectWireCaptureTests.cs`.
|
||||
|
||||
## Format
|
||||
|
||||
One frame per line: `> ` = client→server, `< ` = server→client, then the full encapsulation
|
||||
frame (24-byte header + data) as uppercase hex with no spaces. `#` lines are comments; each file
|
||||
opens with a comment block naming its client, server, profile, tag string and what the frames are.
|
||||
`CaptureFile.Load` reads them; `CaptureFileTests` checks every frame's header length field
|
||||
against its data length and that requests and replies alternate.
|
||||
|
||||
Values are **little-endian on the wire** and carry live per-session values — session handle,
|
||||
connection IDs, connection serial, PCCC TNS. A parity test must compare structure and the
|
||||
fields it builds, not those session-assigned values.
|
||||
|
||||
### Simulator behaviour that is NOT controller behaviour
|
||||
|
||||
- **ab_server zeroes the reply sender context.** libplctag sends a non-zero 8-byte sender context
|
||||
on every SendRRData request (e.g. `45A0091C00000000` in `logix-forward-open.hex`), and every
|
||||
ab_server reply carries `0000000000000000`. A real target echoes the request's context.
|
||||
Consequences: parity tests must **exclude** the sender-context field (header bytes 12–19), and
|
||||
the native transport **must not depend on the echo** to correlate replies, or it will pass
|
||||
against a real controller and fail against the sim, or the reverse.
|
||||
- **ab_server types a STRING as atomic `D0 00`, not as a structure.** A STRING read reply (see
|
||||
`logix-read-string.hex`) is `D2 00 00 00` + type `D0 00` + 88 data bytes: a 4-byte `LEN`
|
||||
(`07 00 00 00` for `"HelloAB"`), the 82-byte `DATA` field, and 2 pad bytes. Write requests
|
||||
(`logix-write-string.hex`) echo the same `D0 00` type. A real Logix STRING is a structure:
|
||||
type `A0 02` followed by the 2-byte structure handle, then the same 88-byte body. Tasks 7 and 8
|
||||
should treat the `D0 00` type as simulator behaviour. Byte parity on the type field holds only
|
||||
against this sim, and a STRING codec must accept the `A0 02` + handle form from a controller.
|
||||
- **ab_server refuses libplctag's BOOL write.** libplctag sends two data bytes (`01 00`) for a
|
||||
one-byte BOOL, and ab_server's "too much data" check (`cip.c` in `handle_write_request`) answers
|
||||
CIP 0x03. That is why there is no BOOL write fixture. `logix-read-bool.hex` was seeded by a raw
|
||||
UCMM Write Tag carrying one data byte (`client seed-bool`, below). Incidentally, that write shows
|
||||
ab_server accepts an Unconnected Send (0x52) routed `01 00` in ControlLogix mode.
|
||||
|
||||
## How they were taken
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Date | 2026-10-07 |
|
||||
| Repo commit | `a0897fda` (fixture sources identical to the files on the docker host, checked by md5) |
|
||||
| Client | **libplctag 1.5.2** (libplctag.NET NuGet, `Directory.Packages.props`) → libplctag.NativeImport 1.0.41 → native core **2.6.0** (read via `plc_tag_get_int_attribute(0,"version_*")`) |
|
||||
| Server image | **`otopcua-ab-server:libplctag-release`** (ab_server from libplctag `v2.7.0` + vendored patches 0001 `@tags` listing and 0002 tag-not-found → CIP 0x04; both confirmed live — see `logix-tags-listing.hex` / `logix-read-unknown-tag.hex`) |
|
||||
| AbCip compose | `/opt/otopcua-abcip` on `10.100.0.35` (= `tests/.../AbCip.IntegrationTests/Docker/`), host `:44818` |
|
||||
| AbLegacy compose | `~/otopcua-ablegacy` on `10.100.0.35` (= `tests/.../AbLegacy.IntegrationTests/Docker/`), host `:44819` slc500 / `:44820` micrologix / `:44821` plc5 |
|
||||
| Capture point | `tcpdump -i eth0 tcp port 44818` in a `nicolaka/netshoot:v0.13` sidecar sharing the simulator container's network namespace; `tshark` 4.2.4 in the same image; segments re-joined into frames by the 24-byte header length field |
|
||||
| Recipe | `scripts/ab-capture/capture.sh run <container> <name> -- scripts/ab-capture/capture.sh client …`, then `capture.sh tohex …` |
|
||||
| Client | `scripts/ab-capture/client/` (`ab-capture-client.csproj`). Not in the solution; pins libplctag 1.5.2 with CPM off. `capture.sh client …` builds it once and runs it |
|
||||
|
||||
The client (`scripts/ab-capture/client/Program.cs`) is a small console program over libplctag 1.5.2 that mirrors how
|
||||
`LibplctagTagRuntime` / `LibplctagLegacyTagRuntime` / `LibplctagTagEnumerator` build a tag
|
||||
(`Gateway`, `Path`, `PlcType`, `Protocol=ab_eip`, `Name`, `Timeout=5s`, `ElementCount` when > 1;
|
||||
no other attributes), then runs `InitializeAsync` + one `ReadAsync` or `WriteAsync`, disposes the
|
||||
tag and calls `plc_tag_shutdown` so the Forward Close reaches the wire. **One fresh session per
|
||||
capture**, one profile up at a time. Note that `InitializeAsync` itself performs a read, so a
|
||||
"read" capture holds two read exchanges and a "write" capture holds a read then the write.
|
||||
|
||||
Values were seeded before the reads so that no decode oracle is all zeros. Seeding commands
|
||||
(they were not captured):
|
||||
|
||||
```bash
|
||||
C=scripts/ab-capture/capture.sh
|
||||
$C client write 10.100.0.35:44818 1,0 ControlLogix TestDINT dint 1 123456
|
||||
$C client write 10.100.0.35:44818 1,0 ControlLogix TestString string 1 HelloAB
|
||||
$C client write 10.100.0.35:44818 1,0 ControlLogix TestArray dintseq 16 x # element i (0-based) = ((i+1)<<24)|(i+1)
|
||||
$C client seed-bool 10.100.0.35 44818 TestBOOL 1 # raw UCMM, see above
|
||||
```
|
||||
|
||||
The `logix-write-*` captures are themselves the seeds for TestDINT and TestString. Each read
|
||||
capture is then, for example:
|
||||
|
||||
```bash
|
||||
CAPDIR=<fresh dir> $C run otopcua-ab-server-controllogix logix-read-dint-array16 -- \
|
||||
$C client read 10.100.0.35:44818 1,0 ControlLogix TestArray raw 16
|
||||
$C tohex <fresh dir>/logix-read-dint-array16.pcap logix-read-dint-array16.full.hex
|
||||
```
|
||||
|
||||
### Per-file record
|
||||
|
||||
All files: libplctag 1.5.2 (native 2.6.0), image `otopcua-ab-server:libplctag-release`. The
|
||||
tag string is the libplctag attribute set the wrapper was given.
|
||||
|
||||
| File | Profile | Tag string | Frames |
|
||||
|---|---|---|---|
|
||||
| `logix-register-session.hex` | controllogix | `protocol=ab_eip&gateway=10.100.0.35:44818&path=1,0&plc=controllogix&name=TestDINT` | RegisterSession (frames 1–2 of the `logix-read-dint` session) |
|
||||
| `logix-forward-open.hex` | controllogix | same as above | Large Forward Open **0x5B** (frames 3–4) |
|
||||
| `logix-read-dint.hex` | controllogix | `…&plc=controllogix&name=TestDINT` | 2× connected Read Tag Fragmented 0x52 (value 123456) |
|
||||
| `logix-read-dint-array16.hex` | controllogix | `…&name=TestArray&elem_count=16` | 2× 0x52, 16 elements; element i (0-based) = `((i+1)<<24)\|(i+1)`, i.e. `0x01000001` … `0x10000010` (LE on the wire: `01 00 00 01`, `02 00 00 02` …) |
|
||||
| `logix-read-string.hex` | controllogix | `…&name=TestString` | 2× 0x52, STRING `"HelloAB"` |
|
||||
| `logix-read-bool.hex` | controllogix | `…&name=TestBOOL` | 2× 0x52, BOOL **1** (seeded via `client seed-bool`) |
|
||||
| `logix-write-dint.hex` | controllogix | `…&name=TestDINT` | 0x52 read, then Write Tag 0x4D of 123456 |
|
||||
| `logix-write-string.hex` | controllogix | `…&name=TestString` | 0x52 read, then 0x4D of `"HelloAB"` |
|
||||
| `logix-read-unknown-tag.hex` | controllogix | `…&name=NoSuchTag` | 0x52 → general status **0x04** (patch 0002); libplctag raised `ErrorNotFound`, no retry |
|
||||
| `logix-tags-listing.hex` | controllogix | `…&name=@tags` | Get Instance Attribute List **0x55** on Symbol class 0x6B (patch 0001); all 9 tags in one reply, status 0x00 |
|
||||
| `logix-forward-close.hex` | controllogix | `…&name=TestDINT` | Forward Close 0x4E (last 2 frames of the `logix-read-dint` session) |
|
||||
| `micro800-read-dint.hex` | micro800 | `protocol=ab_eip&gateway=10.100.0.35:44818&plc=micro800&name=TestDINT` | **Full session**: RegisterSession, Large FO, 2× 0x52 (value 987654), Forward Close |
|
||||
| `slc500-read-n7.hex` | slc500 | `protocol=ab_eip&gateway=10.100.0.35:44819&path=1,0&plc=slc500&name=N7:0` | Full session: RegisterSession, 2× Execute PCCC 0x4B / PCCC 0x0F-0xA2 (value 4321) |
|
||||
| `slc500-write-n7.hex` | slc500 | same | Full session: 0x0F-0xA2 read, then 0x0F-0xAA write of 4321 |
|
||||
| `slc500-read-f8.hex` | slc500 | `…:44819&path=1,0&plc=slc500&name=F8:0` | Full session: 2× 0x0F-0xA2 (value 3.5) |
|
||||
| `micrologix-read-n7.hex` | micrologix | `protocol=ab_eip&gateway=10.100.0.35:44820&plc=micrologix&name=N7:0` | Full session: 2× 0x0F-0xA2 (value 2468) |
|
||||
| `plc5-read-n7.hex` | plc5 | `protocol=ab_eip&gateway=10.100.0.35:44821&path=1,0&plc=plc5&name=N7:0` | Full session: 2× PCCC 0x0F-**0x01** PLC-5 typed read (value 1357) |
|
||||
|
||||
Logix files other than `register-session`/`forward-open`/`forward-close` hold **only the
|
||||
operation's exchanges**; the handshake frames of their sessions are byte-for-byte the same shape
|
||||
as those three files. The Micro800 and PCCC files hold the **whole session** so their routing is
|
||||
visible in one place. No session in any family sent UnregisterSession (0x66); each ends with a TCP RST after the last reply.
|
||||
|
||||
## Routing observed
|
||||
|
||||
What libplctag 2.6.0 actually put on the wire. Tasks 9, 12 and 14 take their `Routing` choice
|
||||
from this table.
|
||||
|
||||
| Family | ForwardOpen | CIP request wrapped in Unconnected Send (0x52) | Route bytes | Requestor-id bytes (PCCC) |
|
||||
|---|---|---|---|---|
|
||||
| controllogix (`path=1,0`) | **yes** — Large FO 0x5B only (0x54 never sent; the 0x5B succeeded). FO itself is sent direct to the Connection Manager `20 06 24 01`, not wrapped. Connection path `01 00 20 02 24 01` (3 words), net params `0x42000FA2`, RPI 1 000 000 µs, transport `0xA3` | **no** — requests ride the connection (SendUnitData, item 0xB1) | `01 00` (port 1, slot 0), carried only inside the FO connection path | n/a |
|
||||
| micro800 (no path) | **yes** — Large FO 0x5B, connection path `20 02 24 01` (2 words, no port segment). ⚠️ Not UCMM; see concerns | **no** — connected | none | n/a |
|
||||
| slc500 (`path=1,0`) | **no** | **no** — Execute PCCC 0x4B sent unconnected (SendRRData, item 0xB2) straight to `20 67 24 01` (PCCC object) | **none — the `1,0` path was given but never appeared on the wire** | `07 3D F3 45 43 50 21` (length 7, vendor 0xF33D, serial 0x21504345) |
|
||||
| micrologix (no path) | **no** | **no** — as slc500 | none | `07 3D F3 45 43 50 21` |
|
||||
| plc5 (`path=1,0`) | **no** | **no** — as slc500 | **none — `1,0` not emitted** | `07 3D F3 45 43 50 21` |
|
||||
| logixpccc (`path=1,0`, against the controllogix profile) | **no** | **yes** — Unconnected Send 0x52 to `20 06 24 01`, priority/tick `0A 05`, embedded 0x4B (len 0x1C) | `01 00` (path size 1 word, reserved, then `01 00`) | `07 3D F3 45 43 50 21` |
|
||||
|
||||
**logixpccc is not reachable**: ab_server's ControlLogix mode has no PCCC object and answers
|
||||
`CB 00 08 00` (service not supported). Its request shape above is read from the client side of
|
||||
that rejected exchange; **no `.hex` fixture was committed for it**, since a parity fixture whose
|
||||
reply is an error would teach nothing about the success path.
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
# logix-forward-close.hex
|
||||
# client : libplctag 1.5.2 (libplctag.NET wrapper; libplctag.NativeImport 1.0.41, native core 2.6.0)
|
||||
# server : otopcua-ab-server:libplctag-release (ab_server from libplctag v2.7.0 + patches 0001/0002)
|
||||
# profile: controllogix
|
||||
# tag : protocol=ab_eip&gateway=10.100.0.35:44818&path=1,0&plc=controllogix&name=TestDINT
|
||||
# session: logix-read-dint (captured 2026-10-07, scripts/ab-capture/capture.sh)
|
||||
# frames : Forward Close 0x4E request/reply (last two frames of the logix-read-dint session). No UnregisterSession followed.
|
||||
> 6F002800F3A5081A00000000F0B223110000000000000000000000000100020000000000B20018004E02200624010A05B1723DF3454350210300010020022401
|
||||
< 6F001E00F3A5081A00000000000000000000000000000000000000000100020000000000B2000E00CE000000B1723DF3454350210000
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user