Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b25ee10a14 | |||
| 8e2dbc6721 | |||
| f73c2db599 | |||
| ee3ffa90ef | |||
| cc3bea8370 | |||
| 352ea449e9 | |||
| 2cea1a0687 | |||
| b75e013d0a | |||
| b66972b6f5 | |||
| f3e92fec27 | |||
| 1982af8155 | |||
| 313946e6ea | |||
| 1e68121b17 | |||
| dcc35f3481 |
@@ -977,17 +977,32 @@ Dev/test LDAP is the **shared GLAuth** running on the Linux Docker host at `10.1
|
||||
|
||||
AbCip and AbLegacy have a second, managed wire transport (`Driver.Ab.Transport`) beside libplctag, chosen by
|
||||
the driver-config key `transport` = `Native` | `Libplctag` (string; takes effect when the driver is
|
||||
constructed — every process start, so **the upgrade restart alone moves no-key drivers to Native**; set
|
||||
`"transport": "Libplctag"` BEFORE upgrading to stay put; AdminUI "Transport" dropdown; CLIs `--transport`). **Native is the default (Task 22):**
|
||||
an ABSENT key resolves to `Native` via `AbCipTransportResolver` / `AbLegacyTransportResolver` (driver, probe and
|
||||
CLI share them) — except an AbLegacy driver with any PLC-5 / `LogixPccc` device, which resolves to `Libplctag`.
|
||||
"Default" is encoded by omitting the key; the AdminUI must never re-emit a concrete default. Evidence: AbCip
|
||||
reads proven on a real CompactLogix (Z-35162, read-only); AbCip writes and all AbLegacy are `ab_server`-only.
|
||||
Fall back with `"transport": "Libplctag"`. ⚠️ AbLegacy native refuses status-bit sub-element
|
||||
writes (`T4:0.DN`…, client sees `BadNotSupported`; refusals never evict or degrade) and its ST byte order is unverified. Client-visible: native reports `BadTypeMismatch` where libplctag returned
|
||||
Good garbage for a same-width mis-authored type (DINT authored, REAL returned). libplctag is confined to
|
||||
`Libplctag*.cs` adapters by `LibplctagConfinementGuardTests`; do not reference it elsewhere. See
|
||||
`docs/drivers/AbCip.md` §Transport, `docs/drivers/AbLegacy.md`, and
|
||||
**constructed** — every process start, or a deploy that recreates it, not `ReinitializeAsync`; AdminUI
|
||||
"Transport" dropdown; CLIs `--transport`). "Default" is encoded by omitting the key; the AdminUI must never
|
||||
re-emit a concrete default. The defaults differ per driver (final review, 2026-10-07):
|
||||
|
||||
- **AbCip: an absent key resolves to the node default, `Native` unless pinned.** Precedence (one place,
|
||||
`AbCipTransportResolver`, shared by driver, probe and CLI): explicit per-driver key → node-level appsetting
|
||||
**`Drivers:AbCip:DefaultTransport`** (`Native` | `Libplctag`, env `Drivers__AbCip__DefaultTransport`,
|
||||
`AbCipDriverDefaultsOptions`; a bad value **fails host start**) → compiled `Native`. Bound unconditionally
|
||||
because the driver factory AND the admin-node Test Connect probe both read it. ⚠️ So **the upgrade restart
|
||||
alone moves no-key AbCip drivers to Native**; to stay on libplctag, set the node key — NOT a per-driver key
|
||||
through the old AdminUI, whose typed form drops unknown keys (#576). Evidence: AbCip reads proven on a real
|
||||
CompactLogix (Z-35162, read-only); AbCip writes are `ab_server`-only.
|
||||
- **AbLegacy: an absent key resolves to `Libplctag` for every family; `Native` is opt-in** (no node key). On
|
||||
a wire the native PCCC path is proven only for **N-file reads/writes and one F8 read**, all on `ab_server`;
|
||||
`S:0` (the default probe), B/T/C/R/S/L/ST/A/O/I and the T/C/R sub-element offsets are encoded from the PCCC
|
||||
table and **unverified natively**. Explicit Native on PLC-5/`LogixPccc` logs a once-per-instance Warning.
|
||||
AbLegacy native refuses status-bit sub-element writes (`T4:0.DN`…, `BadNotSupported`) and its ST byte order
|
||||
is unverified.
|
||||
|
||||
Refusals are not comms faults: an Unsupported refusal (`BadNotSupported`) and an AbCip native **type
|
||||
mismatch** (`BadTypeMismatch`, where libplctag returned Good garbage for a same-width mis-authored type) both
|
||||
keep the tag handle, leave driver health alone (no `Degraded`, nothing for the comms-loss watchdog) and warn
|
||||
once per tag — only genuine transport failures evict and degrade. `DeviceSessionPool.Shared` logs through the
|
||||
host logger factory (wired in `DriverFactoryBootstrap`). libplctag is confined to `Libplctag*.cs` adapters by
|
||||
`LibplctagConfinementGuardTests`; do not reference it elsewhere. See `docs/drivers/AbCip.md` §Transport,
|
||||
`docs/drivers/AbLegacy.md` §Transport + §Wire evidence, `docs/Configuration.md` §`Drivers:AbCip`, and
|
||||
`docs/plans/2026-10-07-native-ab-transport-gate-results.md`.
|
||||
|
||||
## Library Preferences
|
||||
|
||||
@@ -445,6 +445,30 @@ for the enablement runbook (ApiKey handling, stop/start-together rule, tombstone
|
||||
never-`sqlite3`-a-live-WAL-DB inspection rules) and the "pair-local config cache" section of
|
||||
[`docs/Redundancy.md`](Redundancy.md) for what boot-from-cache does and does not cover.
|
||||
|
||||
### `Drivers:AbCip` (node-level AbCip defaults)
|
||||
|
||||
- **Purpose:** pin the wire transport an **AbCip driver with no `transport` key** runs on, for every such
|
||||
driver on this node, without editing driver configs (final review I1 of the native AB transport). The
|
||||
per-driver `DriverConfig` key `transport` still wins when present. The main use is an upgrade: the old
|
||||
AdminUI cannot author the per-driver key (its typed form drops unknown keys, #576), so this is the way to
|
||||
keep a fleet on libplctag across the upgrade restart. See
|
||||
[`drivers/AbCip.md` § Transport](drivers/AbCip.md#transport-transport-option).
|
||||
- **Options class:** `AbCipDriverDefaultsOptions` (`SectionName = "Drivers:AbCip"`) —
|
||||
`src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/AbCipDriverDefaultsOptions.cs`. Bound and validated
|
||||
unconditionally in `Program.cs` (`AddValidatedOptions` + `ValidateOnStart`), because both consumers need
|
||||
it: the driver factory (driver role) and the AdminUI Test Connect probe (admin role), so the probe
|
||||
exercises the stack the driver would run.
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `DefaultTransport` | string | `Native` | `Native` or `Libplctag`, case-insensitive. Blank/absent = `Native`. **Any other value fails host start** (`AbCipDriverDefaultsOptionsValidator`, naming the key), including numeric (`1`) and flags (`Native,Libplctag`) forms. Env form: `Drivers__AbCip__DefaultTransport`. |
|
||||
|
||||
Precedence for an AbCip driver: explicit `transport` in its `DriverConfig` → this key → compiled `Native`.
|
||||
Like the per-driver key, it takes effect when a driver instance is **constructed** (process start, or a
|
||||
deploy that recreates the driver), not on `ReinitializeAsync`. The init log line reads
|
||||
`transport=Libplctag (node default)` when the pin is what selected the stack. There is **no AbLegacy
|
||||
counterpart**: an AbLegacy driver with no key always runs libplctag (Native is opt-in per driver).
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
@@ -458,6 +482,7 @@ All names are read in this repo's source via `Environment.GetEnvironmentVariable
|
||||
| `OTOPCUA_ROLES` | `EffectiveRoles.Resolve` (`ZB.MOM.WW.OtOpcUa.Cluster`) — the single role authority both `Program.cs` and `AddOtOpcUaCluster` consume | Comma-separated cluster roles for the node (`admin`, `driver`, `dev`, `cluster-{ClusterId}`), read as the **fallback** only when `Cluster:Roles` is empty. Drives the conditional wiring, the per-role appsettings overlay, and the node's Akka member roles — all from the same resolved instance. See the note under `Cluster:Roles` above. |
|
||||
| `OTOPCUA_CONFIG_CONNECTION` | `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/DesignTimeDbContextFactory.cs` (design-time / `dotnet ef` only) | Read at **design time** by `DesignTimeDbContextFactory.cs` for `dotnet ef` migrations. At **runtime** the server resolves the connection string from `ConnectionStrings:ConfigDb` (env form: `ConnectionStrings__ConfigDb`) via `configuration.GetConnectionString("ConfigDb")` in `ServiceCollectionExtensions.cs` — `OTOPCUA_CONFIG_CONNECTION` appears there only as a hint in an error message, not via `GetEnvironmentVariable`. No credential is embedded in source. |
|
||||
| `ASPNETCORE_ENVIRONMENT` | ASP.NET host builder (framework) | Selects `appsettings.{Environment}.json` (e.g. `Development`). |
|
||||
| `Drivers__AbCip__DefaultTransport` | configuration binder (`AbCipDriverDefaultsOptions`) | Env form of [`Drivers:AbCip:DefaultTransport`](#driversabcip-node-level-abcip-defaults): `Native` (default) or `Libplctag` for AbCip drivers with no `transport` key. A bad value fails host start. |
|
||||
|
||||
### Historian (`ServerHistorian__ApiKey`)
|
||||
|
||||
|
||||
+1
-1
@@ -595,7 +595,7 @@ unknown keys are preserved byte-stable on round-trip by all typed editors.
|
||||
| **S7** | `ReadBytesAsync` block + per-element decode loop | Unit-proven (fixture down) |
|
||||
| **AB CIP** | libplctag native array read (atomic + UDT member arrays) | Unit-proven (fixture down) |
|
||||
| **AB Legacy** | PCCC multi-element file read via libplctag (cap 256 elements) | Unit-proven (fixture down) |
|
||||
| **TwinCAT** | ADS native array symbol read | Unit-proven (fixture down) |
|
||||
| **TwinCAT** | Raw-bytes array read + per-element decode (native notifications carry the whole array) | Live-verified on TwinCAT 2.11 (2026-10-07) |
|
||||
|
||||
Array writes, multi-dimensional arrays (ValueRank>1), and per-element historization are out of scope — see [Uns.md §Array tags](Uns.md#array-tags-1-d).
|
||||
|
||||
|
||||
+1
-1
@@ -310,7 +310,7 @@ Combined with historization (values are arrays — history of the whole array sn
|
||||
| **S7** | Contiguous `ReadBytesAsync` block over the declared address span + per-element decode loop | Unit-proven (sim fixture down) |
|
||||
| **AB CIP** | libplctag native array read (atomic and UDT member arrays) | Unit-proven (sim fixture down) |
|
||||
| **AB Legacy** | PCCC multi-element file read via libplctag (cap 256 elements) | Unit-proven (sim fixture down) |
|
||||
| **TwinCAT** | ADS native array symbol read against the declared `SymbolPath` | Unit-proven (sim fixture down) |
|
||||
| **TwinCAT** | Raw-bytes read of the declared `SymbolPath` at the resolved array type's layout + per-element decode; native ADS notifications carry the whole array (same decode). Multi-dim PLC arrays publish flattened; a shorter `arrayLength` reads the leading sub-range | Live-verified 2026-10-07 on two TwinCAT 2.11 PLCs (see [TwinCAT.md §Array tags](drivers/TwinCAT.md#array-tags)) |
|
||||
|
||||
### Out of scope (named deferrals)
|
||||
|
||||
|
||||
+44
-20
@@ -89,23 +89,28 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
|
||||
|
||||
| Key | Values | Default | Serialization |
|
||||
|---|---|---|---|
|
||||
| `transport` | absent \| `Native` \| `Libplctag` | absent ⇒ `Native` | string, case-insensitive on read, written as `"Libplctag"` / `"Native"`; "use the default" is encoded by OMITTING the key |
|
||||
| `transport` | absent \| `Native` \| `Libplctag` | absent ⇒ the node's `Drivers:AbCip:DefaultTransport` (itself default `Native`) | string, case-insensitive on read, written as `"Libplctag"` / `"Native"`; "use the default" is encoded by OMITTING the key |
|
||||
|
||||
- **`Native`** (the default since Task 22) is a managed in-process EtherNet/IP + CIP client
|
||||
(`Driver.Ab.Transport`).
|
||||
- **`Libplctag`** (fallback) drives the libplctag native library, as before.
|
||||
- An absent key means "use the default". It is resolved in one place, `AbCipTransportResolver`, which the
|
||||
driver, the Test Connect probe and the CLI all go through. An explicit value always wins.
|
||||
driver, the Test Connect probe and the CLI all go through. **Precedence:** an explicit per-driver key →
|
||||
the node-level appsetting **`Drivers:AbCip:DefaultTransport`** (`Native` | `Libplctag`, env
|
||||
`Drivers__AbCip__DefaultTransport`; a bad value fails host start; see
|
||||
[Configuration.md](../Configuration.md#driversabcip-node-level-abcip-defaults)) → compiled `Native`. The
|
||||
Test Connect probe uses the same node value, so it probes the stack the driver would run. The CLI has no
|
||||
node config and uses compiled `Native`.
|
||||
- A bad value fails like a bad `PlcFamily`; the Test Connect probe reports "Config JSON is invalid".
|
||||
- A change takes effect when the driver instance is **constructed**: on every process start (so the upgrade
|
||||
restart moves no-key drivers to Native — see the release note) and when a deploy that changes `DriverConfig`
|
||||
does this (`DriverHostActor` respawns the child, #516). `ReinitializeAsync` alone keeps the transport
|
||||
it was constructed with.
|
||||
- AdminUI: the **Transport** dropdown in the advanced section of the AbCip driver form ("Default (Native)" /
|
||||
"Native (managed)" / "libplctag (fallback)"). **Default omits the key on save**, so a defaulted driver stays
|
||||
- AdminUI: the **Transport** dropdown in the advanced section of the AbCip driver form ("Default (node
|
||||
setting; Native unless pinned)" / "Native (managed)" / "libplctag (fallback)"). **Default omits the key on save**, so a defaulted driver stays
|
||||
defaulted; saving another field preserves an explicit value and preserves absence.
|
||||
- The driver logs `transport=<effective> (default|configured)` at Information on every init, e.g.
|
||||
`transport=Native (default)`, including reconnect re-inits. AbCip
|
||||
`transport=Native (default)` or `transport=Libplctag (node default)`, including reconnect re-inits. AbCip
|
||||
drivers now receive the host logger (they previously ran on a null logger, so none of their warnings
|
||||
reached the node log); expect previously invisible warnings, e.g. a probe enabled with no probe tag path.
|
||||
- CLI: `--transport default|native|libplctag` (default `default`, which goes through the same resolver).
|
||||
@@ -118,20 +123,34 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
|
||||
`Native`; libplctag remains available as the fallback.
|
||||
- ⚠️ **Client-visible:** native reports **`BadTypeMismatch`** for a mis-authored same-width type (for example a
|
||||
tag authored `DINT` where the controller holds a `REAL`), where libplctag returned Good garbage. Correct the
|
||||
authored type; see [Native transport behaviour](#native-transport-behaviour).
|
||||
- **To fall back**, set `"transport": "Libplctag"` in the driver config (or pick "libplctag (fallback)" in
|
||||
the AdminUI dropdown).
|
||||
authored type; see [Native transport behaviour](#native-transport-behaviour). It does not degrade driver
|
||||
health or churn the connection; one Warning per tag names it.
|
||||
- **To fall back** one driver, set `"transport": "Libplctag"` in its driver config (or pick "libplctag
|
||||
(fallback)" in the AdminUI dropdown). To fall back a whole node, set `Drivers:AbCip:DefaultTransport` to
|
||||
`Libplctag` (below).
|
||||
- ⚠️ **When it takes effect: the upgrade restart itself.** Every process start constructs the drivers
|
||||
(including boot-from-cache on a driver-only node), so **every driver with no `transport` key moves to Native
|
||||
the moment the upgraded node starts** — no deploy is needed. To keep a driver on libplctag, add
|
||||
`"transport": "Libplctag"` and deploy it **before upgrading**: older builds ignore the unknown key. After the
|
||||
upgrade, a transport change takes effect when a deploy recreates the driver (not on `ReinitializeAsync`).
|
||||
(including boot-from-cache on a driver-only node), so **every driver with no `transport` key moves to the
|
||||
node default — `Native` unless pinned — the moment the upgraded node starts**; no deploy is needed. After the
|
||||
upgrade, a per-driver transport change takes effect when a deploy recreates the driver (not on
|
||||
`ReinitializeAsync`).
|
||||
- **To stay on libplctag across the upgrade (pin before upgrading):** set the node-level key on each node
|
||||
you upgrade, in the same change that installs the new build — `appsettings.json`
|
||||
`"Drivers": { "AbCip": { "DefaultTransport": "Libplctag" } }`, or the environment variable
|
||||
`Drivers__AbCip__DefaultTransport=Libplctag` on the service. Older builds ignore the section, so it can be
|
||||
staged ahead of the binaries. Every AbCip driver with no `transport` key then starts on libplctag; the init
|
||||
log shows `transport=Libplctag (node default)`. Move to Native later per driver (explicit
|
||||
`"transport": "Native"` in the new AdminUI) or for the node (remove the key / set `Native`, then restart).
|
||||
⚠️ Do **not** rely on adding `"transport": "Libplctag"` to driver configs through the **old** AdminUI before
|
||||
upgrading: its typed AbCip form has no Transport field and drops unknown keys on save (#576), and on a fused
|
||||
`admin,driver` node the AdminUI and drivers upgrade together, so there is no window. The node key works on
|
||||
every node shape, including fused nodes and driver-only nodes booting from cache.
|
||||
- **Evidence, and its limits:** AbCip **reads** (plus browse and templates) were proven on a production
|
||||
CompactLogix 5069-L320ERS2 (**Z-35162**, read-only, 2026-10-08; see
|
||||
[gate results](../plans/2026-10-07-native-ab-transport-gate-results.md) §Real press). AbCip **writes** are
|
||||
simulator-proven only (`ab_server`), as is all of AbLegacy.
|
||||
- AbLegacy has its own exclusion: a defaulted AbLegacy driver with any PLC-5 or `logixpccc` device stays on
|
||||
libplctag (see [AbLegacy.md §Transport](AbLegacy.md#transport-transport-option)).
|
||||
simulator-proven only (`ab_server`).
|
||||
- **AbLegacy is different: its default stays libplctag** and Native is opt-in per driver; the native PCCC
|
||||
path has wire evidence (on `ab_server`) only for N-file reads/writes and one F8 read (see
|
||||
[AbLegacy.md §Transport](AbLegacy.md#transport-transport-option)).
|
||||
|
||||
## Native transport behaviour
|
||||
|
||||
@@ -140,8 +159,12 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
|
||||
request in flight; large reads (listing pages, Read Template) are sent unpacked and exclusive.
|
||||
- **Forward Open throttle:** at most one attempt in flight, at least 1 s between starts, bounded by the
|
||||
open budget.
|
||||
- `ConnectionSize` / `Timeout` are honoured per pooled connection, and the **first** driver/device to open
|
||||
a given device key wins. ControlLogix, GuardLogix and (since T21) CompactLogix default to 4002, a Large
|
||||
- `ConnectionSize` / `Timeout` / `AllowPacking` are honoured per pooled connection, and the **first**
|
||||
driver/device to open a given device key wins — including a Test Connect probe on a fused `admin,driver`
|
||||
node (its connection carries the probe timeout). A later opener with different options is ignored and
|
||||
logged once as a Warning (`DeviceSessionPool`). ⚠️ The device key compares the **host string**, so the
|
||||
same controller addressed by DNS name in one driver and by IP in another gets two connections and two
|
||||
Forward Open throttles (up to two opens per second); address a controller the same way everywhere. ControlLogix, GuardLogix and (since T21) CompactLogix default to 4002, a Large
|
||||
Forward Open (0x5B), the same as libplctag. A target that refuses Large gets one regular open at
|
||||
min(size, 504) inside the same throttled attempt. An explicit `ConnectionSize` in the options or on the
|
||||
device overrides the family default.
|
||||
@@ -154,11 +177,12 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
|
||||
holds a `REAL` returns the REAL's bits as an integer with a Good status under libplctag, and
|
||||
`BadTypeMismatch` natively. That is the fix working, but a consumer will see Bad quality on tags that
|
||||
previously looked healthy: correct the authored type. Same-width signed/unsigned pairs are exempt, and
|
||||
writes are gated on a mismatch too.
|
||||
writes are gated on a mismatch too. A mismatch is a **deterministic configuration refusal**, handled like
|
||||
an Unsupported refusal (final review I4): the tag's handle is kept (no re-create, no reconnect), driver
|
||||
health is unchanged (no `Degraded`, nothing fed to the comms-loss watchdog), and the Warning is logged
|
||||
**once per tag** per driver instance, not every poll. The same applies to the libplctag-path width check.
|
||||
- Malformed tag names put the device into backoff, as under libplctag (#570). STRING capacity includes the
|
||||
alignment pad (84, not 82) (#570).
|
||||
- Status-bit sub-element writes (e.g. `T4:0.DN`) are Unsupported natively (they would clobber the control
|
||||
word); nothing goes on the wire. Sub-element arrays are Unsupported natively.
|
||||
- **A refused operation is not a comms fault.** Any operation a runtime refuses as Unsupported (in AbCip, e.g.
|
||||
a native write whose type cannot be determined) reaches the client as `BadNotSupported`, keeps the tag
|
||||
handle, and leaves driver health unchanged; only real transport failures evict and degrade.
|
||||
|
||||
+58
-50
@@ -93,7 +93,7 @@ binds from the driver's `DriverConfig` JSON:
|
||||
- **`Devices`** — one `AbLegacyDeviceOptions` per PLC (`HostAddress`, `PlcFamily`, optional `DeviceName`).
|
||||
- **`Tags`** — pre-declared `AbLegacyTagDefinition` list (`Name`, `DeviceHostAddress`, `Address`, `DataType`, `Writable`, `WriteIdempotent`).
|
||||
- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeAddress`.
|
||||
- **`Transport`** — absent (default ⇒ `Native`, but `Libplctag` when any device is PLC-5 / `LogixPccc`), `Native`, or `Libplctag` (fallback); see [Transport](#transport-transport-option).
|
||||
- **`Transport`** — absent (default ⇒ `Libplctag`), `Native` (opt-in), or `Libplctag`; see [Transport](#transport-transport-option).
|
||||
|
||||
Full per-field descriptions live in the contracts assembly. The JSON skeleton is
|
||||
reproduced in [docs/v2/driver-specs.md §4](../v2/driver-specs.md).
|
||||
@@ -105,66 +105,72 @@ written as `"Libplctag"` / `"Native"`; "use the default" is encoded by omitting
|
||||
PCCC-over-EtherNet/IP client (`Driver.Ab.Transport`).
|
||||
|
||||
**Default resolution** (one place, `AbLegacyTransportResolver`, used by the driver, the Test Connect probe and
|
||||
the CLI): an absent key resolves to `Native`, **unless any configured device has `PlcFamily` `Plc5` or
|
||||
`LogixPccc`** — then the whole driver resolves to `Libplctag`, because the transport is chosen once per driver,
|
||||
not per device. An explicit value always wins: explicit `Native` with a PLC-5 / `LogixPccc` device is allowed
|
||||
(an operator choice) and logs one Warning per driver instance naming the limitation.
|
||||
|
||||
⚠️ **Test Connect in a mixed-family driver can disagree with the runtime.** The AdminUI probe merges the
|
||||
driver config with **only the device being probed**, so in a defaulted driver holding an SLC 500 and a PLC-5,
|
||||
Test Connect on the SLC device resolves `Native` while the deployed driver runs every device on `Libplctag`.
|
||||
SLC 500 works on both stacks, so this is cosmetic; the reverse (probe on libplctag, driver on native) cannot
|
||||
happen. Set `transport` explicitly to make them agree.
|
||||
the CLI): an absent key resolves to **`Libplctag` for every PLC family** — unchanged from before the native
|
||||
transport existed. **`Native` is opt-in** (final review I3: on a wire it is proven only for N-file reads and
|
||||
writes and one F8 read — see [Wire evidence](#wire-evidence-native-pccc)). An explicit value always wins:
|
||||
explicit `Native` with a PLC-5 / `LogixPccc` device is allowed (an operator choice) and logs one Warning per
|
||||
driver instance naming the limitation. (AbCip's node-level `Drivers:AbCip:DefaultTransport` has no AbLegacy
|
||||
counterpart — the AbLegacy default is libplctag and is not node-configurable.)
|
||||
|
||||
A change takes effect when the driver instance is constructed: on a deploy that changes `DriverConfig`
|
||||
(which recreates it) **and on every process start**, including the restart that installs this build (see the
|
||||
release note). The
|
||||
AdminUI form's **Transport** dropdown offers "Default (Native; libplctag for PLC-5 / LogixPccc)" (omits the
|
||||
key on save), "Native (managed)" and "libplctag (fallback)". The driver logs
|
||||
`transport=<effective> (default|configured)` on every init, e.g. `transport=Libplctag (default; PLC-5/LogixPccc
|
||||
device present)`. CLI: `--transport default|native|libplctag` (default `default`, so `--plc-type Plc5` with no
|
||||
`--transport` runs libplctag). See
|
||||
AdminUI form's **Transport** dropdown offers "Default (libplctag)" (omits the key on save), "Native (managed,
|
||||
opt-in)" and "libplctag". The driver logs `transport=<effective> (default|configured)` on every init, e.g.
|
||||
`transport=Libplctag (default)`. CLI: `--transport default|native|libplctag` (default `default` = libplctag for
|
||||
every `--plc-type`). See
|
||||
[AbCip.md §Transport](AbCip.md#transport-transport-option) for the shared behaviour (one pooled connection
|
||||
per device, Forward Open throttle, routing).
|
||||
|
||||
Native routing: SLC500/MicroLogix/PLC-5 are direct; `logixpccc` uses UnconnectedSend and its SLC framing is
|
||||
**unverified**.
|
||||
|
||||
### Release note — native is the default (Task 22)
|
||||
### Release note — native PCCC transport, opt-in (final review I3)
|
||||
|
||||
- ⚠️ **Status-bit writes are refused on the default.** An SLC 500 / MicroLogix driver on the native default
|
||||
**refuses writes to status-bit sub-elements** — `T4:0.DN`, `T4:0.TT`, `T4:0.EN`, `C5:0.CU`, `C5:0.DN`,
|
||||
`R6:0.EN`, and the rest. The client sees **`BadNotSupported`**. It is not treated as a comms fault: the
|
||||
tag's handle is kept and driver health is unchanged. **Nothing is written to the PLC.** libplctag performed a masked bit write here, so an
|
||||
authored writable status-bit tag that worked before the upgrade fails after it. This is deliberate: a native
|
||||
read-modify-write of the control word would race the PLC updating TT/DN and could clobber EN/TT/DN, and such
|
||||
writes are rare. **Drivers that need them must set `"transport": "Libplctag"`.** Reads of status bits are
|
||||
unaffected. Word sub-elements (`T4:0.PRE`, `T4:0.ACC`, `R6:0.LEN`, `R6:0.POS`) still write natively.
|
||||
- **The native managed transport is now the default** for SLC 500 and MicroLogix. A driver config with no
|
||||
`transport` key runs `Native`; libplctag remains available as the fallback.
|
||||
- **Exclusion:** a defaulted driver with **any PLC-5 or `LogixPccc` device stays on libplctag**. Native PLC-5
|
||||
support is N-file reads only, and `LogixPccc` framing is unverified. Explicit `Native` is still honoured
|
||||
there, with a Warning at init.
|
||||
- ⚠️ **Client-visible:** native reports **`BadTypeMismatch`** for a mis-authored same-width type, where
|
||||
libplctag returned Good garbage (see [AbCip.md §Native transport behaviour](AbCip.md#native-transport-behaviour)).
|
||||
- **ST (string) files:** the native ST file byte order is **unverified** (no capture), so string values read or
|
||||
written natively may be byte-swapped. Drivers with ST tags whose values matter should fall back.
|
||||
- **To fall back**, set `"transport": "Libplctag"` in the driver config.
|
||||
- ⚠️ **When it takes effect: the upgrade restart itself.** Every process start constructs the drivers
|
||||
(including boot-from-cache on a driver-only node), so **every driver with no `transport` key moves to
|
||||
Native the moment the upgraded node starts** — no deploy is needed. To keep a driver on libplctag, add
|
||||
`"transport": "Libplctag"` and deploy it **before upgrading**: older builds ignore the unknown key. After the
|
||||
upgrade, a transport change takes effect when a deploy recreates the driver (not on `ReinitializeAsync`).
|
||||
- **Evidence, and its limits:** all of AbLegacy is **simulator-proven only** (`ab_server`). No real SLC,
|
||||
MicroLogix or PLC-5 has been exercised. (The real-press gate, Z-35162 2026-10-08, covered AbCip reads only.)
|
||||
- **The default is unchanged: libplctag.** A driver config with no `transport` key runs `Libplctag`, for
|
||||
every PLC family, exactly as before this release. The upgrade restart moves nothing. (An interim build on
|
||||
this branch defaulted SLC 500 / MicroLogix to Native; that was reverted before merge.)
|
||||
- **`"transport": "Native"` opts a driver into the managed PCCC-over-EtherNet/IP client** (`Driver.Ab.Transport`).
|
||||
It takes effect when the driver is constructed — a deploy that changes `DriverConfig`, or a process start —
|
||||
not on `ReinitializeAsync`. The AdminUI **Transport** dropdown authors it; remove the key (or pick
|
||||
"Default (libplctag)") to go back.
|
||||
- **Known limits of Native** (why it is opt-in):
|
||||
- ⚠️ **Wire evidence is narrow and simulator-only** — see [Wire evidence](#wire-evidence-native-pccc). Only
|
||||
N-file reads and writes and one F8 read have touched a wire (`ab_server`). The **default probe address
|
||||
`S:0`** and the **B, T, C, R, S, L, ST, A, O and I file types** — and the T/C/R sub-element offsets
|
||||
(`.PRE`/`.ACC`/`.LEN`/`.POS`) — are encoded from the PCCC file-type table and are **unverified natively**.
|
||||
No real SLC, MicroLogix or PLC-5 has been exercised. Opt in per driver after checking it against the real
|
||||
controller.
|
||||
- ⚠️ **Status-bit writes are refused** — `T4:0.DN`, `T4:0.TT`, `T4:0.EN`, `C5:0.CU`, `C5:0.DN`, `R6:0.EN`
|
||||
and the rest return **`BadNotSupported`**; nothing is written to the PLC; the handle is kept and health is
|
||||
unchanged. libplctag performs a masked bit write here; a native read-modify-write of the control word would
|
||||
race the PLC updating TT/DN. Reads of status bits are unaffected.
|
||||
- **PLC-5 native is N-file reads only**; every other PLC-5 operation is refused with `BadNotSupported`.
|
||||
`LogixPccc` framing is unverified. Explicit Native on either logs a Warning at init.
|
||||
- **ST (string) byte order is unverified** (no capture); string values may be byte-swapped.
|
||||
- Unlike AbCip, the native AbLegacy runtime has **no element-type check**, so a mis-authored same-width
|
||||
type reads as reinterpreted data (as on libplctag), not `BadTypeMismatch`.
|
||||
|
||||
### Wire evidence (native PCCC)
|
||||
|
||||
Everything below is against the `ab_server` simulator; no real PCCC controller has been exercised.
|
||||
|
||||
| What | Evidence |
|
||||
|---|---|
|
||||
| N-file read (`N7:0`) | libplctag captures `slc500-read-n7`, `micrologix-read-n7`, `plc5-read-n7` (codec byte parity) + native `AbLegacyReadSmokeTests` read on all three profiles |
|
||||
| N-file write (`N7:5`) | capture `slc500-write-n7` + native `AbLegacyReadSmokeTests` write/read round trip (SLC 500) |
|
||||
| F-file read (`F8`) | one capture, `slc500-read-f8` (codec parity only; no native F write on a wire) |
|
||||
| S (incl. the default probe `S:0`), B, T, C, R, L, ST, A, O, I | **none** — encoded from the PCCC file-type table |
|
||||
| T/C/R sub-elements (`.PRE`/`.ACC`/`.LEN`/`.POS`), 6-byte T/C/R element layout | **none** |
|
||||
|
||||
### Native known gaps
|
||||
|
||||
- ⚠️ **Simulator-only.** PCCC reads and writes are verified against `ab_server` only. No real SLC/MicroLogix/
|
||||
PLC-5 has been exercised; the real-press gate (Task 21) was read-only and AbCip-only.
|
||||
- ⚠️ **Simulator-only, and narrow.** Only N-file reads/writes and one F8 read are on a wire (`ab_server`); see
|
||||
[Wire evidence](#wire-evidence-native-pccc). No real SLC/MicroLogix/PLC-5 has been exercised; the real-press
|
||||
gate (Task 21) was read-only and AbCip-only.
|
||||
- **PLC-5 native is N-file reads only.** Every other file type and all PLC-5 writes are refused before the
|
||||
wire and reach the client as `BadNotSupported` (handle kept, health unchanged; #571 closed). The default excludes PLC-5 and
|
||||
`logixpccc` (they resolve to libplctag unless `Native` is set explicitly).
|
||||
wire and reach the client as `BadNotSupported` (handle kept, health unchanged; #571 closed). Native is opt-in
|
||||
for every family; an explicit Native on PLC-5 / `logixpccc` logs a Warning at init.
|
||||
- Status-bit sub-element writes (e.g. `T4:0.DN`, `C5:0.DN`) are Unsupported natively (they would clobber the
|
||||
control word); nothing goes on the wire. Sub-element arrays are Unsupported natively. L-file bit indexes
|
||||
above 15 are allowed.
|
||||
@@ -184,14 +190,16 @@ An AB Legacy tag becomes a **1-D OPC UA array node** when its `TagConfig` JSON c
|
||||
`"isArray": true` and `"arrayLength": N` (N ≥ 1, capped at 256). The canonical rule:
|
||||
`isArray: true` + `arrayLength >= 1` → array; `isArray: false` (any length) → scalar.
|
||||
|
||||
**Read mechanism** — the driver issues a **PCCC multi-element file read** via libplctag.
|
||||
The tag's file address (e.g. `N7:0`) is extended by `N` element count so libplctag fetches
|
||||
**Read mechanism** — the driver issues a **PCCC multi-element file read** (libplctag by default; the opt-in
|
||||
native transport issues the same typed read, chunked under the PCCC payload limit).
|
||||
The tag's file address (e.g. `N7:0`) is extended by `N` element count so the transport fetches
|
||||
`N` consecutive PCCC file elements in one EIP/PCCC request. The response is decoded into
|
||||
N values of the declared `AbLegacyDataType`. Maximum element count is 256 (PCCC payload
|
||||
limit).
|
||||
|
||||
This closes the previously-noted limitation ("single-element addressing today") for the
|
||||
array read path. Array *writes* remain a follow-up.
|
||||
array read path. The opt-in native transport also writes multi-element arrays (chunked); on libplctag
|
||||
array *writes* remain a follow-up.
|
||||
|
||||
**Unit test coverage** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` covers
|
||||
the multi-element read path via the fake tag runtime. The AB Legacy integration fixture is
|
||||
@@ -199,7 +207,7 @@ down during normal dev.
|
||||
|
||||
**Live-verify** — integration-fixture-gated (requires the AB Legacy Docker fixture).
|
||||
|
||||
**Deferrals** — array *writes*, multi-dimensional arrays, per-element historization.
|
||||
**Deferrals** — array *writes* on libplctag (the opt-in native transport writes them), multi-dimensional arrays, per-element historization.
|
||||
`arrayLength > 256` is rejected at init with an explicit error (PCCC payload cap).
|
||||
|
||||
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
|
||||
|
||||
@@ -93,6 +93,11 @@ All tests gate on `TWINCAT_TARGET_NETID` (required) via `[TwinCATFact]` /
|
||||
`BrowseSymbolsAsync` + system-symbol filtering
|
||||
- `TwinCATNativeNotificationTests` — `AddDeviceNotification` registration,
|
||||
callback-delivery-to-`OnDataChange` wiring, unregister on unsubscribe
|
||||
- `TwinCATNativeArraySubscribeTests` — array tags register with their authored
|
||||
length, replay after reconnect, and a refused tag is isolated with its status
|
||||
- `TwinCATArrayCodecTests` — array layout planning / validation
|
||||
(`TwinCATTypeGuard.CheckArray`, size cap) + raw-bytes decode of every element type
|
||||
- `TwinCATBitAccessTests` — bit-indexed BOOL notifications served from the parent word
|
||||
- `TwinCATDriverTests` — `IDriver` lifecycle
|
||||
|
||||
Capability surfaces whose contract is verified at the unit layer: `IDriver`,
|
||||
|
||||
+118
-20
@@ -202,6 +202,15 @@ a batching delay for high-churn signals. Set `UseNativeNotifications=false` for
|
||||
deployments where the AMS router has notification limits you can't raise — then
|
||||
the driver falls through to the shared poll engine.
|
||||
|
||||
Strings, arrays and bit-indexed BOOLs are registered as **raw bytes** at the PLC's
|
||||
resolved width and decoded by the driver, with the same rules the poll read uses
|
||||
(see [Strings](#strings-and-declared-type-checking) and [Array tags](#array-tags)).
|
||||
A tag the driver refuses to register — a declared-type mismatch (`BadTypeMismatch`)
|
||||
or an array over the 128 KiB notification cap (`BadOutOfRange`) — is isolated: it
|
||||
publishes that status once, logs one WARN, and the rest of the subscription
|
||||
registers. An ADS error at registration (e.g. a symbol that does not exist) still
|
||||
fails the whole subscribe call.
|
||||
|
||||
## Single-connection-per-device
|
||||
|
||||
Each device's ADS client is lazily connected and serialized by a per-device
|
||||
@@ -219,32 +228,121 @@ writes use the driver-wide `Timeout`.
|
||||
- **CLI** — [Driver.TwinCAT.Cli.md](../Driver.TwinCAT.Cli.md) documents the
|
||||
standalone read/write/browse/probe CLI for manual checks.
|
||||
|
||||
## 1-D array support
|
||||
## Array tags
|
||||
|
||||
A TwinCAT tag becomes a **1-D OPC UA array node** when its `TagConfig` JSON carries
|
||||
`"isArray": true` and `"arrayLength": N` (N ≥ 1). The canonical rule:
|
||||
`isArray: true` + `arrayLength >= 1` → array; `isArray: false` (any length) → scalar.
|
||||
A TwinCAT tag becomes a **1-D OPC UA array node** (ValueRank 1, `ArrayDimensions = [N]`) when
|
||||
its `TagConfig` JSON carries `"isArray": true` and `"arrayLength": N` (N ≥ 1). The canonical
|
||||
rule: `isArray: true` + `arrayLength >= 1` → array; `isArray: false` (any length) → scalar.
|
||||
`DataType` is the **element** type.
|
||||
|
||||
**Read mechanism** — the driver performs an **ADS native array symbol read** against the
|
||||
declared `SymbolPath`. ADS returns the full array value buffer for a symbol that maps to a
|
||||
TwinCAT array type (e.g. `ARRAY [0..9] OF REAL`); the driver reads `N` elements from the
|
||||
response.
|
||||
### One decode for poll and native notifications
|
||||
|
||||
⚠️ **Native notifications do not carry arrays.** The notification path registers the
|
||||
declared *scalar* type, so before the declared-type check an array tag subscribed natively
|
||||
published only its first element at Good. It is now refused with `BadTypeMismatch` (the PLC
|
||||
symbol is an array, not a scalar). Subscribe array tags with `UseNativeNotifications=false`
|
||||
(the poll engine uses the array read above) until native array notifications are added.
|
||||
An array is read — by a poll **and** by a native notification — as **raw bytes** at a layout
|
||||
planned from the PLC's **resolved** array type (`TwinCATTypeGuard.CheckArray`), and decoded by
|
||||
one codec (`TwinCATArrayCodec`). Both paths therefore publish the identical value: the
|
||||
element-typed CLR array (`bool[]`, `sbyte[]`, `byte[]`, `short[]`, `ushort[]`, `int[]`,
|
||||
`uint[]`, `long[]`, `ulong[]`, `float[]`, `double[]`, `string[]`; `TIME` / `DATE` / `DT` /
|
||||
`TOD` elements as `uint[]` — the same CLR shape their scalars publish), N elements long.
|
||||
|
||||
**Unit test coverage** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/` covers
|
||||
the array symbol read path via the fake ADS client factory. The TwinCAT integration
|
||||
fixture is down during normal dev.
|
||||
- **A native notification registers the whole array** (element stride × N) as raw bytes, so
|
||||
the PLC pushes the array on change and every notification decodes to the full array. Before
|
||||
this the notification path registered only the declared *scalar* — publishing element 0 at
|
||||
Good — and the declared-type check then refused every array tag. `UseNativeNotifications=false`
|
||||
is no longer needed for arrays.
|
||||
- **Elements are checked like a scalar notification**: the declared element type must match the
|
||||
PLC element's width, float/integer kind and string kind (`DInt` over `ARRAY OF REAL` → refused;
|
||||
`Int` over `ARRAY OF UINT` — same width, same kind — tolerated). `BYTE` elements map to `SInt`.
|
||||
- **STRING(n) / WSTRING(n) arrays** are laid out at the PLC's own element width (n+1 /
|
||||
2(n+1)); each element ends at its own first NUL and decodes with the same Windows-1252 /
|
||||
UTF-8 rules as a scalar string (see above).
|
||||
- **Multi-dimensional arrays** (`ARRAY [1..3, 1..2] OF REAL`) are published **flattened** in PLC
|
||||
memory order (the last index varies fastest); `arrayLength` is the total element count (6).
|
||||
**Lower bounds play no part**: a PLC `ARRAY [1..20]` is published 0-based, element 0 = PLC
|
||||
index 1.
|
||||
- **Sub-range:** an `arrayLength` **shorter** than the PLC array reads the **leading N
|
||||
elements** (N × stride bytes from the array start) — a smaller read and a smaller
|
||||
notification, and the value always matches the node's advertised `ArrayDimensions`. A
|
||||
**longer** `arrayLength` is refused (`BadTypeMismatch`) rather than reading past the array.
|
||||
- **Refused with `BadTypeMismatch`** (one WARN, that tag only — the rest of the subscription
|
||||
registers and stays Good): an element-type mismatch, a too-long `arrayLength`, an
|
||||
**array of structs / UDTs / FBs**, an array of pointers, a nested (jagged) array, an array tag
|
||||
over a non-array symbol, a `VAR_IN_OUT` array reference, and — on the read path too now — a
|
||||
**scalar** tag over a PLC array (author it as an array tag, or address one element). Before,
|
||||
a scalar poll over an array made the Beckhoff read throw and surfaced as
|
||||
`BadCommunicationError` with the driver Degraded.
|
||||
- **Element and bit paths stay scalars.** `Arr[3]` is a scalar tag on one element (native and
|
||||
poll). A bit-indexed BOOL (`MAIN.Flags.3`, `.ADSSTATE_RUN.0`) registers its notification on the
|
||||
**parent word** as raw bytes and reads the bit — ADS has no symbol for `WORD.N`, and
|
||||
registering one used to fail with `DeviceSymbolNotFound` and take the whole subscribe call
|
||||
down (found live, below). A bit outside the parent's width, or a bit of a non-integer parent,
|
||||
is refused with `BadTypeMismatch`.
|
||||
|
||||
**Live-verify** — integration-fixture-gated (requires the TwinCAT Docker fixture / AMS router).
|
||||
### Notification size cap
|
||||
|
||||
**Deferrals** — array *writes* (`ADS WriteValueAsync` for an array), multi-dimensional
|
||||
arrays, per-element historization. `IRediscoverable` fires and now surfaces a `/hosts` re-browse prompt, but still does not rebuild the address space (#518)
|
||||
on PLC re-download (unchanged semantics from scalar nodes).
|
||||
ADS documents no notification size limit, and a live TwinCAT 2.11 target registered and
|
||||
delivered a **131,072-byte** notification (`P_ETHERCATDIAGNOSIS.SLAVEADDRESS`,
|
||||
`ARRAY [0..65535] OF UINT`) without complaint. The driver still caps a native array at
|
||||
**128 KiB** (`TwinCATTypeGuard.MaxNotificationArrayBytes`, the largest size proven live): the
|
||||
PLC compares the whole buffer every cycle for an on-change notification, and every change is
|
||||
copied through the router and the driver's notification queue. A bigger array is refused for
|
||||
native subscription with **`BadOutOfRange`** (one WARN, that tag only) — author a shorter
|
||||
`arrayLength` (sub-range) or subscribe with `UseNativeNotifications=false`; poll reads have no
|
||||
cap.
|
||||
|
||||
### Why raw bytes, not the Beckhoff typed array read
|
||||
|
||||
The poll read used to ask the Beckhoff library for `elementType[]`. Probed live on Z33856, that
|
||||
failed for most real arrays: the CLR element type had to match the PLC's **exactly** (an
|
||||
`ARRAY OF TIME` as `uint[]` threw `ArgumentException`), a multi-dimensional array demanded a
|
||||
rank-N CLR array (`Single[,]`), and a dotted **member** array was sliced by its under-reported
|
||||
TwinCAT 2 symbol size (`P_INPUTVALUESFILTER.FBUSPWR1G1.SAMPLE`, `ARRAY [1..20] OF REAL`:
|
||||
symbol 10 B vs type 80 B → `ArgumentOutOfRangeException`). Each surfaced as
|
||||
`BadCommunicationError`. The raw-bytes model sizes everything from the resolved type, as the
|
||||
string path already does.
|
||||
|
||||
### Live validation (2026-10-07, TwinCAT 2.11 Elma cleaners)
|
||||
|
||||
Read-only, against Z33856 (`10.201.45.236.1.1:801`) and Z33733 (`5.25.53.46.1.1:801`) through
|
||||
an in-process `Beckhoff.TwinCAT.Ads.TcpRouter` scratch harness; one connection at a time, no
|
||||
writes. Browse found 594 / 583 array symbols (509 / 500 of primitives, 15 / 14 of strings,
|
||||
17 / 11 multi-dimensional, ~400 with a non-zero lower bound). Each tag was subscribed natively
|
||||
for 10 s alongside a 1 s poll read; native and poll matched in value, CLR element type and
|
||||
length for every array, and matched an independent Beckhoff typed read wherever that read
|
||||
works:
|
||||
|
||||
| Symbol | PLC type | Tag | Published |
|
||||
|---|---|---|---|
|
||||
| `.ARRFAULTCODE` | `ARRAY [0..12] OF UINT` | UInt × 13 | `UInt16[13]` (element 3 = 32, matching `.ARRFAULTCODE[3]` scalar) |
|
||||
| `.ARRUSPOWER` | `ARRAY [1..3, 1..2] OF REAL` / `[1..2, 1..2]` | Real × 6 / 4 | `Single[6]` / `Single[4]` — changing (~1060 W on Z33733); **23/23** notifications identical to an immediate poll |
|
||||
| `P_INPUTVALUESFILTER.FBUSPWR1G1.SAMPLE` | `ARRAY [1..20] OF REAL` (member, symbol 10 B) | Real × 20, and × 5 (sub-range) | `Single[20]` changing, **14/14** identical; `Single[5]` = its first 5 |
|
||||
| `.THROTTLE_MODE_DEFAULT` | `ARRAY [0..12] OF TIME` | Time × 13 | `UInt32[13]` `[0, 10, 20, 40, …]` ms |
|
||||
| `.ARGTYPE_IS_BIT_TYPE` | `ARRAY [0..18] OF BOOL` | Bool × 19 | `Boolean[19]` |
|
||||
| `.FORMAT_DEFAULT_PRECISION` / `.DWORD_DECADE_DIVIDER` / `.DIXTIMEALARM` | `INT` / `DWORD` / `UDINT` arrays | Int / UDInt | `Int16[12]` / `UInt32[10]` / `UInt32[101]` |
|
||||
| `.IMQN_TABLE` / `.AMSNETID` / `.ARRMODULENO` | `LREAL` / `USINT` / `BYTE` arrays | LReal / USInt / SInt | `Double[32]` / `Byte[6]` / `SByte[13]` (changing on Z33856) |
|
||||
| `FAST.COMPORTCONTROL.COMPORTDEBUGBUFFER.BUFFER` | `ARRAY [0..300] OF INT` (member) | Int × 301 | `Int16[301]`, changing every cycle; 18–19 of ~59 notifications identical to an immediate poll (the rest changed in between) |
|
||||
| `P_ALARMS.ARRSTATE` / `.ARRHEADER` | `ARRAY [1..n] OF STRING(80)` | String | `["RUN", "RUN", "RUN", "RUN", "MON_ON", "", …]` / `["Time1", "UsTime1 bottom", …]` |
|
||||
| `.ARRDATA` | `ARRAY [0..5, 0..17] OF STRING(80)` (8,748 B) | String × 108 | `String[108]` |
|
||||
| `P_ETHERCATDIAGNOSIS.SLAVEADDRESS` | `ARRAY [0..65535] OF UINT` (131,072 B) | UInt × 65536 | `UInt16[65536]` `[1001, 1002, …]` — at the cap |
|
||||
|
||||
Refused per tag with `BadTypeMismatch`, on both paths, while every sibling stayed Good: `DInt`
|
||||
over `.ARRUSPOWER` (REAL elements), `arrayLength` 14 over the 13-element `.ARRFAULTCODE`,
|
||||
`DInt` × 13 over `.ARRCARRIERDATA_V` (`ARRAY OF ST_CARRIERDATA`), scalar `UInt` over
|
||||
`.ARRFAULTCODE`, and an array of 2 over the scalar `.ADSSTATE_RUN`. The bit tag
|
||||
`.ADSSTATE_RUN.0` = TRUE natively (it previously failed to register at all).
|
||||
|
||||
### Coverage and deferrals
|
||||
|
||||
**Unit tests** — `TwinCATArrayCodecTests` (layout planning, element checks, sub-range, size
|
||||
cap, decode of every element type), `TwinCATNativeArraySubscribeTests` (the driver passes the
|
||||
authored length, replays it after a reconnect, isolates a refused tag with the refusal's own
|
||||
status), `TwinCATBitAccessTests`. The Beckhoff `AdsClient` cannot be faked, so the adapter
|
||||
stays a thin shell over these pure classes.
|
||||
|
||||
**Deferrals** — array *writes* (`ADS WriteValueAsync` for an array), per-element
|
||||
historization, and **controller browse of multi-dimensional arrays** (discovery still drops
|
||||
them; author such a tag by hand with `arrayLength` = the total element count).
|
||||
`IRediscoverable` fires and now surfaces a `/hosts` re-browse prompt, but still does not
|
||||
rebuild the address space (#518) on PLC re-download (unchanged semantics from scalar nodes).
|
||||
|
||||
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
|
||||
and the UI authoring flow.
|
||||
|
||||
@@ -223,7 +223,27 @@ There were **zero** 0x4D/0x53/0x4B. Both aborts (runs 1 and 5) were the simulato
|
||||
across TCP segments (1448 + 606), and ab_server answered with FIN. The harness aborted on the first connection-level failure,
|
||||
as designed. A real controller reassembles TCP; ab_server does not.
|
||||
|
||||
The T21 press result is recorded under `## Latency (T21)` and is not filled in here.
|
||||
The T21 press result is recorded under `### Pass 3 — 2026-10-08 02:27–02:30 UTC (HEAD `8e2dbc67`, after the final-review fixes; same settings as pass 2) — with packet capture
|
||||
|
||||
**17/17 passed.** Browse 3162 = 3162 tags; 20 UDT templates identical; 200 tags read with 0 unexpected divergences
|
||||
(6 live); native 1 Forward Open, Forward Closes sent/acked 1/1; latency native p95 105.1 ms ≤ libplctag p95 197.3 ms
|
||||
×1.10 = 217.1 ms → PASS. Raw output: [pass 3](2026-10-07-native-ab-transport-t21/t21-pass3.txt).
|
||||
|
||||
Packet capture (`tcpdump -i utun4 host 10.209.70.160 and port 44818`, analysed with TCP sequence reassembly):
|
||||
|
||||
| Session | Order | Window (s) | CIP services seen (requests) | Forward Close target |
|
||||
|---|---|---|---|---|
|
||||
| libplctag differential | 1 | 0.0 – 82.5 | 0x03, 0x4C, 0x52, 0x55, 0x5B ×1, 0x4E ×1 | Connection Manager 6/1 |
|
||||
| native differential | 2 | 82.5 – 126.3 | 0x03, 0x4C, 0x52, 0x55, 0x5B ×1, 0x4E ×1 (+ UnRegisterSession) | Connection Manager 6/1 |
|
||||
| libplctag latency | 3 | 126.5 – 152.4 | 0x0A (MSP), 0x52, 0x5B ×1, 0x4E ×1 | Connection Manager 6/1 |
|
||||
| native latency | 4 | 152.4 – 176.5 | 0x0A (MSP), 0x52, 0x5B ×1, 0x4E ×1 (+ UnRegisterSession) | Connection Manager 6/1 |
|
||||
|
||||
- **Zero write-class services** (no 0x4D Write Tag, 0x53 Write Tag Fragmented, 0x4B Execute PCCC, and no 0x4E to
|
||||
anything but the Connection Manager).
|
||||
- **Four sessions, strictly sequential** — each starts after the previous one's last packet; never two at once.
|
||||
- Every session opened exactly one Large Forward Open and closed it with one Forward Close.
|
||||
|
||||
## Latency (T21)` and is not filled in here.
|
||||
|
||||
## AdminUI /run (T19)
|
||||
|
||||
@@ -342,7 +362,7 @@ Failures, and what they led to:
|
||||
| Writes | none — read-only source guard passes; `grep -c WriteAsync` = 0 over every Differential file |
|
||||
| libplctag failed-create orphans | 15 destroyed by the #578 fix, 0 rooted — no crash at exit |
|
||||
|
||||
⚠️ **Pass 2 has no independent packet capture**: the operator's `tcpdump` had stopped at 23:45 UTC, before pass 2 began,
|
||||
⚠️ **Pass 2 has no independent packet capture** (pass 3 below re-ran it with one): the operator's `tcpdump` had stopped at 23:45 UTC, before pass 2 began,
|
||||
and this was not noticed until afterwards. The read-only evidence for pass 2 is the source guard and the harness's
|
||||
own Forward Open/Close counters; pass 1's capture is the wire-level proof for the same code paths.
|
||||
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
|
||||
Test run for /Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll (.NETCoreApp,Version=v10.0)
|
||||
VSTest version 18.0.1 (arm64)
|
||||
|
||||
Starting test execution, please wait...
|
||||
A total of 1 test files matched the specified pattern.
|
||||
/Users/dohertj2/Desktop/OtOpcUa/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/bin/Debug/net10.0/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.dll
|
||||
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.0.2+dd36e86129 (64-bit .NET 10.0.5)
|
||||
[xUnit.net 00:00:00.35] Discovering: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
[xUnit.net 00:00:00.49] Discovered: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
[xUnit.net 00:00:00.60] Starting: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Template_parity_fails_when_vacuous_or_undecodable [9 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_native_phase_after_exactly_one_connect [2 s]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.The_default_selection_keeps_arrays_and_udt_members_at_any_cap [7 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.Volume_knobs_fail_fast_above_the_ceiling_instead_of_clamping [2 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.DifferentialSafetyTests.A_failing_endpoint_aborts_the_libplctag_phase_before_any_tag_is_read [6 s]
|
||||
Standard Output Messages:
|
||||
libplctag connects before the abort: 1
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Tag_pick_takes_atomic_scalars_only_and_repeats_a_short_set_visibly [13 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.PollLatencyComparisonTests.Native_poll_p95_is_within_ten_percent_of_libplctag [2 m 56 s]
|
||||
Standard Output Messages:
|
||||
## Latency — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
|
||||
Tags per pass: 95 (100 distinct); passes per transport: 50; gap between passes: 200 ms.
|
||||
Types: SInt 6, DInt 8, Bool 17, Real 7, Int 57
|
||||
Dropped (libplctag refused at setup, removed from both sets): Local:1:I.Pad1, Local:2:I.Pad1, Local:3:I.Pad1, Local:5:C.CfgSize, Local:5:C.CfgIDNum
|
||||
Native requests spent on setup (one read per tag): 95; native Forward Opens: 1; native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
|
||||
libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
|
||||
|
||||
| Transport | Passes | p50 ms | p95 ms | min ms | max ms | CIP requests per pass (median) | Tag failures |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| libplctag | 50 | 101.8 | 197.3 | 54.6 | 219.2 | n/a | 0 |
|
||||
| native | 50 | 99.4 | 105.1 | 76.0 | 139.3 | 2 | 0 |
|
||||
|
||||
Criterion: native p95 105.1 ms ≤ libplctag p95 197.3 ms × 1.10 = 217.1 ms → PASS
|
||||
|
||||
| Pass | libplctag ms | libplctag GC 0/1/2 | native ms | native GC 0/1/2 | native requests | native RTT min/mean/max ms |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | 99.9 | 0/0/0 | 99.6 | 0/0/0 | 2 | 45.0/49.4/53.8 |
|
||||
| 2 | 101.7 | 0/0/0 | 101.3 | 0/0/0 | 2 | 48.1/50.5/53.0 |
|
||||
| 3 | 100.0 | 0/0/0 | 99.6 | 0/0/0 | 2 | 45.7/49.7/53.7 |
|
||||
| 4 | 102.4 | 0/0/0 | 98.9 | 1/0/0 | 2 | 48.2/49.4/50.5 |
|
||||
| 5 | 105.8 | 0/0/0 | 97.1 | 0/0/0 | 2 | 44.8/48.5/52.2 |
|
||||
| 6 | 103.5 | 0/0/0 | 99.8 | 0/0/0 | 2 | 45.3/49.8/54.3 |
|
||||
| 7 | 100.5 | 0/0/0 | 105.1 | 0/0/0 | 2 | 51.2/52.5/53.8 |
|
||||
| 8 | 105.7 | 0/0/0 | 94.4 | 0/0/0 | 2 | 43.3/47.1/50.9 |
|
||||
| 9 | 99.8 | 0/0/0 | 99.1 | 0/0/0 | 2 | 47.0/49.5/51.9 |
|
||||
| 10 | 102.8 | 0/0/0 | 98.1 | 0/0/0 | 2 | 48.4/49.0/49.5 |
|
||||
| 11 | 54.6 | 0/0/0 | 98.7 | 0/0/0 | 2 | 44.0/49.3/54.5 |
|
||||
| 12 | 99.8 | 0/0/0 | 105.2 | 0/0/0 | 2 | 44.6/52.5/60.5 |
|
||||
| 13 | 101.5 | 0/0/0 | 94.9 | 0/0/0 | 2 | 43.7/47.4/51.0 |
|
||||
| 14 | 109.8 | 0/0/0 | 98.9 | 0/0/0 | 2 | 46.9/49.4/51.8 |
|
||||
| 15 | 106.7 | 0/0/0 | 96.3 | 0/0/0 | 2 | 43.2/48.0/52.8 |
|
||||
| 16 | 165.0 | 0/0/0 | 99.4 | 0/0/0 | 2 | 46.6/49.6/52.5 |
|
||||
| 17 | 197.3 | 0/0/0 | 101.3 | 0/0/0 | 2 | 45.7/50.6/55.4 |
|
||||
| 18 | 71.0 | 0/0/0 | 98.8 | 0/0/0 | 2 | 45.3/49.3/53.4 |
|
||||
| 19 | 219.2 | 0/0/0 | 101.0 | 0/0/0 | 2 | 48.0/50.4/52.8 |
|
||||
| 20 | 168.8 | 0/0/0 | 100.5 | 0/0/0 | 2 | 46.2/50.1/54.1 |
|
||||
| 21 | 95.3 | 0/0/0 | 98.3 | 0/0/0 | 2 | 45.7/49.0/52.4 |
|
||||
| 22 | 133.9 | 0/0/0 | 98.9 | 0/0/0 | 2 | 46.4/49.4/52.3 |
|
||||
| 23 | 163.0 | 0/0/0 | 97.2 | 0/0/0 | 2 | 44.9/48.5/52.1 |
|
||||
| 24 | 184.5 | 0/0/0 | 101.4 | 0/0/0 | 2 | 49.1/50.6/52.2 |
|
||||
| 25 | 130.9 | 0/0/0 | 97.2 | 0/0/0 | 2 | 43.7/48.5/53.3 |
|
||||
| 26 | 76.8 | 0/0/0 | 103.2 | 0/0/0 | 2 | 47.3/51.5/55.8 |
|
||||
| 27 | 99.6 | 0/0/0 | 76.0 | 0/0/0 | 1 | 75.8/75.8/75.8 |
|
||||
| 28 | 113.8 | 0/0/0 | 100.3 | 0/0/0 | 2 | 46.4/50.1/53.7 |
|
||||
| 29 | 100.9 | 0/0/0 | 96.0 | 0/0/0 | 2 | 44.2/47.9/51.6 |
|
||||
| 30 | 106.0 | 0/0/0 | 103.0 | 0/0/0 | 2 | 49.9/51.4/53.0 |
|
||||
| 31 | 107.3 | 0/0/0 | 103.7 | 0/0/0 | 2 | 47.4/51.8/56.1 |
|
||||
| 32 | 100.9 | 0/0/0 | 102.4 | 0/0/0 | 2 | 47.4/51.1/54.8 |
|
||||
| 33 | 99.9 | 0/0/0 | 98.6 | 0/0/0 | 2 | 46.0/49.2/52.4 |
|
||||
| 34 | 101.4 | 0/0/0 | 98.7 | 0/0/0 | 2 | 46.7/49.3/51.8 |
|
||||
| 35 | 97.8 | 0/0/0 | 98.9 | 0/0/0 | 2 | 46.3/49.4/52.5 |
|
||||
| 36 | 98.7 | 0/0/0 | 98.5 | 0/0/0 | 2 | 45.2/49.2/53.2 |
|
||||
| 37 | 101.2 | 0/0/0 | 100.1 | 0/0/0 | 2 | 45.2/49.9/54.7 |
|
||||
| 38 | 102.6 | 0/0/0 | 100.0 | 0/0/0 | 2 | 44.0/49.9/55.8 |
|
||||
| 39 | 103.5 | 0/0/0 | 97.7 | 0/0/0 | 2 | 45.0/48.8/52.5 |
|
||||
| 40 | 104.5 | 0/0/0 | 99.6 | 0/0/0 | 2 | 46.0/49.7/53.5 |
|
||||
| 41 | 103.0 | 0/0/0 | 99.4 | 0/0/0 | 2 | 45.4/49.6/53.9 |
|
||||
| 42 | 101.8 | 0/0/0 | 98.7 | 0/0/0 | 2 | 46.0/49.2/52.4 |
|
||||
| 43 | 101.8 | 0/0/0 | 139.3 | 0/0/0 | 2 | 45.6/69.6/93.6 |
|
||||
| 44 | 109.4 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.2/48.2/53.3 |
|
||||
| 45 | 101.6 | 0/0/0 | 96.6 | 0/0/0 | 2 | 43.2/48.2/53.2 |
|
||||
| 46 | 101.6 | 0/0/0 | 102.8 | 0/0/0 | 2 | 47.9/51.3/54.8 |
|
||||
| 47 | 204.5 | 0/0/0 | 101.0 | 0/0/0 | 2 | 47.3/50.4/53.5 |
|
||||
| 48 | 108.0 | 0/0/0 | 100.0 | 0/0/0 | 2 | 47.4/49.9/52.4 |
|
||||
| 49 | 99.7 | 0/0/0 | 101.3 | 0/0/0 | 2 | 48.3/50.6/52.8 |
|
||||
| 50 | 99.7 | 0/0/0 | 99.4 | 1/1/1 | 2 | 46.0/49.6/53.2 |
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_closes_its_one_connection_with_exactly_one_forward_close [< 1 ms]
|
||||
Standard Output Messages:
|
||||
Native Forward Closes sent/acked: 1/1; connections after the native phase: 0.
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.No_failed_libplctag_tag_is_left_with_a_live_native_callback [< 1 ms]
|
||||
Standard Output Messages:
|
||||
libplctag orphans destroyed so far: 15; rooted (unreleasable): 0.
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.A_live_value_is_compared_structurally_and_a_stable_one_by_value [3 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_harness_is_read_only_by_construction [36 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Browse_listings_decode_to_identical_tag_sets_through_both_enumerators [6 ms]
|
||||
Standard Output Messages:
|
||||
## Browse — 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
|
||||
libplctag: 3162 tag(s); native: 3162 tag(s)
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_comparison_flags_every_divergence_and_excuses_only_a_native_type_mismatch [< 1 ms]
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_template_decodes_to_an_identical_shape_through_both_readers [< 1 ms]
|
||||
Standard Output Messages:
|
||||
## Templates — 20 id(s); truncated by AB_DIFF_MAX_TEMPLATES: libplctag 74, native 74
|
||||
| Template | libplctag | native | Equal? |
|
||||
|---|---|---|---|
|
||||
| 283 | Recipe (5 members, 1680 B) | Recipe (5 members, 1680 B) | yes |
|
||||
| 295 | UDT_PartStatus (5 members, 4 B) | UDT_PartStatus (5 members, 4 B) | yes |
|
||||
| 344 | Recipe_Clamp (7 members, 420 B) | Recipe_Clamp (7 members, 420 B) | yes |
|
||||
| 491 | ForceSegmentEnableCheck (3 members, 4 B) | ForceSegmentEnableCheck (3 members, 4 B) | yes |
|
||||
| 527 | Recipe_PowerUnit (6 members, 24 B) | Recipe_PowerUnit (6 members, 24 B) | yes |
|
||||
| 622 | UDT_TemperatureProfile_Segment (11 members, 40 B) | UDT_TemperatureProfile_Segment (11 members, 40 B) | yes |
|
||||
| 676 | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | AB:5000_DO8_Diag:I:0 (14 members, 36 B) | yes |
|
||||
| 700 | AB:5000_AI4CJ:I:0 (12 members, 68 B) | AB:5000_AI4CJ:I:0 (12 members, 68 B) | yes |
|
||||
| 706 | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | raM_UDT_ItfAD_5000Digital_Sts (11 members, 320 B) | yes |
|
||||
| 715 | TwoHand_Control (8 members, 16 B) | TwoHand_Control (8 members, 16 B) | yes |
|
||||
| 752 | STR0016 (2 members, 20 B) | STR0016 (2 members, 20 B) | yes |
|
||||
| 842 | STR0032 (2 members, 36 B) | STR0032 (2 members, 36 B) | yes |
|
||||
| 862 | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | raM_UDT_ItfAD_5000Analog_Set (3 members, 4 B) | yes |
|
||||
| 1129 | raM_Tec_LimitWithModulo (38 members, 120 B) | raM_Tec_LimitWithModulo (38 members, 120 B) | yes |
|
||||
| 1144 | PartStatus (8 members, 12 B) | PartStatus (8 members, 12 B) | yes |
|
||||
| 1166 | AB:5000_DI16:C:0 (18 members, 72 B) | AB:5000_DI16:C:0 (18 members, 72 B) | yes |
|
||||
| 1192 | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | raM_UDT_ItfAD_5000Digital_Cmd (5 members, 4 B) | yes |
|
||||
| 1206 | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | raM_Dvc_5000AI4_VCRT (77 members, 180 B) | yes |
|
||||
| 1289 | TimeString (12 members, 376 B) | TimeString (12 members, 376 B) | yes |
|
||||
| 1313 | raM_Dvc_5000AO4 (73 members, 164 B) | raM_Dvc_5000AO4 (73 members, 164 B) | yes |
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.The_native_side_makes_exactly_one_forward_open_over_the_whole_run [< 1 ms]
|
||||
Standard Output Messages:
|
||||
Native Forward Opens over the run: 1.
|
||||
|
||||
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Every_selected_tag_reads_identically_through_both_transports [3 ms]
|
||||
Standard Output Messages:
|
||||
## Reads — 200 tag(s), 10.209.70.160:44818 path '1,0' family CompactLogix (compactlogix, connection size 4002), max tags 200, max templates 20, pace 50 ms
|
||||
Selection by origin: array 20, udt member 20, scalar 160
|
||||
| Tag | Type | libplctag status | native status | libplctag probe | Equal? |
|
||||
|---|---|---|---|---|---|
|
||||
| `Program:Recipes._monitorID` | DInt[255] | Ok | Ok | stable | yes |
|
||||
| `Local:1:I.InfoBits` | SInt | Ok | Ok | stable | yes |
|
||||
| `__CONTAINER` | DInt | Ok | Ok | stable | yes |
|
||||
| `Program:EM01_PowerUnit._VacuumCalibration_mA` | Real[10] | Ok | Ok | stable | yes |
|
||||
| `Local:1:I.RunMode` | Bool | Ok | Ok | stable | yes |
|
||||
| `WW_M4ActualTemp` | Real | Ok | Ok | **live** | live |
|
||||
| `Program:EM01_PowerUnit._VacuumCalibration_PSIA` | Real[10] | Ok | Ok | stable | yes |
|
||||
| `Local:1:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
|
||||
| `WW_P1_Status_Msg` | DInt | Ok | Ok | stable | yes |
|
||||
| `Program:EM01_PowerUnit.__fout9FB1561FA00D73F5` | Real[5] | Ok | Ok | **live** | live |
|
||||
| `Local:1:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
|
||||
| `DI_CycleStart_PB` | Bool | Ok | Ok | stable | yes |
|
||||
| `Program:EM01_PowerUnit.__foutA0A97D0B7D734500` | Real[2] | Ok | Ok | stable | yes |
|
||||
| `Local:1:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
|
||||
| `test` | DInt | Ok | Ok | stable | yes |
|
||||
| `Program:EM01_PowerUnit.__fout2A759FA1596561EC` | Real[2] | Ok | Ok | stable | yes |
|
||||
| `Local:1:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
|
||||
| `UN01_EventsPTR` | DInt | Ok | Ok | stable | yes |
|
||||
| `Program:EM01_PowerUnit._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
|
||||
| `Local:2:I.InfoBits` | SInt | Ok | Ok | stable | yes |
|
||||
| `Sts_Vacuum_Rerun` | Bool | Ok | Ok | stable | yes |
|
||||
| `Program:EM01_PowerUnit.__fout02198332D12EF5E3` | Real[3] | Ok | Ok | stable | yes |
|
||||
| `Local:2:I.RunMode` | Bool | Ok | Ok | stable | yes |
|
||||
| `Ons_VacuumRerun` | Bool | Ok | Ok | stable | yes |
|
||||
| `Program:EM02_Clamp._SEQ_EP` | DInt[32] | Ok | Ok | stable | yes |
|
||||
| `Local:2:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
|
||||
| `test2` | DInt | Ok | Ok | stable | yes |
|
||||
| `Program:EM02_Clamp.__fout79B2713C88B35A28` | Real[3] | Ok | Ok | stable | yes |
|
||||
| `Local:2:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
|
||||
| `_Val_PressurePVx` | Real | Ok | Ok | stable | yes |
|
||||
| `Program:EM02_Clamp.__fout2F67CD35C2302EE0` | Real[4] | Ok | Ok | **live** | live |
|
||||
| `Local:2:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
|
||||
| `C_LimitType_Outside` | DInt | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB` | Real[4] | Ok | Ok | **live** | live |
|
||||
| `Local:2:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
|
||||
| `Par_TargetTemperature_Tolerance` | Real | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_EM01_Zone1.__fout92848E133B44AF24` | Real[8] | Ok | Ok | **live** | live |
|
||||
| `Local:3:I.InfoBits` | SInt | Ok | Ok | stable | yes |
|
||||
| `Cfg_RapidHeatup_Tolerancex` | DInt | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_EM01_Zone1.__fout6DC7745628658A9D` | Real[2] | Ok | Ok | **live** | live |
|
||||
| `Local:3:I.RunMode` | Bool | Ok | Ok | stable | yes |
|
||||
| `Cfg_RapidHeatup_Tolerance` | Real | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_EM01_Zone1.__fout7C6F52924950A71D` | Real[2] | Ok | Ok | stable | yes |
|
||||
| `Local:3:I.ConnectionFaulted` | Bool | Ok | Ok | stable | yes |
|
||||
| `test_stop` | Bool | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_EM01_Zone1.__foutCBB7A04F2668DCD0` | Real[2] | Ok | Ok | stable | yes |
|
||||
| `Local:3:I.DiagnosticActive` | Bool | Ok | Ok | stable | yes |
|
||||
| `Sts_ScreenLock` | Bool | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_TemperatureControl._DisableStates` | DInt[32] | Ok | Ok | stable | yes |
|
||||
| `Local:3:I.DiagnosticSequenceCount` | SInt | Ok | Ok | stable | yes |
|
||||
| `xSts_ClearResult` | Bool | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_TemperatureControl._ModeNames` | String[32] | Ok | Ok | stable | yes |
|
||||
| `Local:3:I.Pad1` | Int | NotFound | NotFound | NotFound | yes |
|
||||
| `WS2_CLOSE_Delay_Setpoint` | Int | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_TemperatureControl._ModeTransitions` | DInt[32] | Ok | Ok | stable | yes |
|
||||
| `Local:5:C.CfgSize` | DInt | NotFound | NotFound | NotFound | yes |
|
||||
| `WS2_Force_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
|
||||
| `Program:ES03_TemperatureControl._SEQ_UP` | DInt[32] | Ok | Ok | stable | yes |
|
||||
| `Local:5:C.CfgIDNum` | DInt | NotFound | NotFound | NotFound | yes |
|
||||
| `WS2_Force_Red_Setpoint` | Real | Ok | Ok | stable | yes |
|
||||
| `C_LimitType_Inside` | DInt | Ok | Ok | stable | yes |
|
||||
| `xxCfg_WaterValve_MinTime` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_Force_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_Force_Yellow` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WW_Green` | Bool | Ok | Ok | stable | yes |
|
||||
| `WW_Down` | Bool | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_F_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_HighMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_LowMold_Red_Setpoint` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_Mold_AlarmDelay_Setpoint` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_Mold_StartDelay_Setpoint` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E10_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E1_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E2_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E3_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E4_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E5_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E6_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E7_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E8_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PH_E9_Mold3TempTermSetpt` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_POSITION_SP` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z1Z3_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLDPLATEN_Z2Z4_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_MOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_RAMP_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG1` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG10` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG2` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG3` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG4` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG5` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG6` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG7` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG8` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_PSI_SP_SEG9` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_SPEED_SP` | Real | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z1Z3_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMPPLTEN_Z2Z4_HOLD_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_HOLD_Time_Setpoint_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG8` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_MOLD_HOLD_TEMP_SEG9` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG1` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG10` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG2` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG3` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG4` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG5` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG6` | Int | Ok | Ok | stable | yes |
|
||||
| `WS2_TEMP_RAMP_SEG7` | Int | Ok | Ok | stable | yes |
|
||||
|
||||
Unexpected divergences: 0; expected divergences: 0; live tags (value moved between the two libplctag reads; compared by status, length, type code, shape and every element that held still): 6 of 200.
|
||||
- live: WW_M4ActualTemp (lib 4 byte(s) 20526943 = 233.3208; lib probe 4 byte(s) 908D6843 = 232.55298; native 4 byte(s) 501F6743 = 231.12231)
|
||||
- live: Program:EM01_PowerUnit.__fout9FB1561FA00D73F5 (lib 20 byte(s) 0050C3487C144C403E0A8640C2F5F9C07C144C40 = [400000,3.1887503,4.1887503,-7.8112497,3.1887503]; lib probe 20 byte(s) 0050C3481C244A400E128540F2EDFAC01C244A40 = [400000,3.158454,4.158454,-7.841546,3.158454]; native 20 byte(s) 0050C34831AA454018D58240E82AFDC031AA4540 = [400000,3.0885127,4.0885124,-7.9114876,3.0885127])
|
||||
- live: Program:EM02_Clamp.__fout2F67CD35C2302EE0 (lib 16 byte(s) 96D41EB99A9919BF00D68349CDCCCCBE = [-0.00015147249,-0.6,1080000,-0.4]; lib probe 16 byte(s) A830FE389A9919BF00D68349CDCCCCBE = [0.00012120727,-0.6,1080000,-0.4]; native 16 byte(s) D34D8FB99A9919BF00D68349CDCCCCBE = [-0.0002733307,-0.6,1080000,-0.4])
|
||||
- live: Program:ES03_EM01_Zone1.__fout25DE96F3D0E2B7BB (lib 16 byte(s) 11E416C300000000000000C000000000 = [-150.89088,0,-2,0]; lib probe 16 byte(s) F1E910C300000000000000C000000000 = [-144.91383,0,-2,0]; native 16 byte(s) 194008C300000000000000C000000000 = [-136.25038,0,-2,0])
|
||||
- live: Program:ES03_EM01_Zone1.__fout92848E133B44AF24 (lib 32 byte(s) 91D52843C4085243000090C10022A14991D504C391D5524391D516430000A8C1 = [168.83424,210.03424,-18,1320000,-132.83424,210.83424,150.83424,-21]; lib probe 32 byte(s) CDD7224398074C43000090C10022A1499AAFFDC265D44C43CDD710430000A8C1 = [162.84297,204.02966,-18,1320000,-126.84297,204.82967,144.84297,-21]; native 32 byte(s) 2D311A4388634343000090C10022A1495A62ECC2553044432D3108430000A8C1 = [154.1921,195.3888,-18,1320000,-118.19209,196.1888,136.1921,-21])
|
||||
- live: Program:ES03_EM01_Zone1.__fout6DC7745628658A9D (lib 8 byte(s) 4DC71643E23E7142 = [150.77852,60.31141]; lib probe 8 byte(s) F5BA104388916742 = [144.7303,57.89212]; native 8 byte(s) 650B0843D5AB5942 = [136.04451,54.417805])
|
||||
|
||||
|
||||
[xUnit.net 00:03:06.03] Finished: ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests
|
||||
Passed ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential.TransportDifferentialTests.Libplctag_and_native_are_never_connected_at_the_same_time [< 1 ms]
|
||||
Standard Output Messages:
|
||||
TCP connections to the target: baseline 0, before native 0, libplctag drain wait 1 ms.
|
||||
|
||||
|
||||
|
||||
Test Run Successful.
|
||||
Total tests: 17
|
||||
Passed: 17
|
||||
Total time: 3.1046 Minutes
|
||||
@@ -27,8 +27,9 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
|
||||
|
||||
/// <summary>Gets the transport stack used to reach the PLC.</summary>
|
||||
[CommandOption("transport", Description =
|
||||
"Transport stack: default, native or libplctag. default = native, except libplctag for --plc-type " +
|
||||
"Plc5 / LogixPccc (native there is PLC-5 N-file reads only, no status-bit writes, LogixPccc unverified).")]
|
||||
"Transport stack: default, native or libplctag. default = libplctag for every --plc-type; native is " +
|
||||
"opt-in (wire-proven only for N-file reads/writes and an F8 read; PLC-5 native is N-file reads only, " +
|
||||
"LogixPccc unverified).")]
|
||||
public AbLegacyCliTransport Transport { get; init; } = AbLegacyCliTransport.Default;
|
||||
|
||||
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
|
||||
|
||||
@@ -49,6 +49,9 @@ public sealed class DeviceSessionPool
|
||||
Shared._logger = loggerFactory.CreateLogger<DeviceSessionPool>();
|
||||
}
|
||||
|
||||
/// <summary>Wiring-test hook: the logger this pool currently writes to (final review I2).</summary>
|
||||
internal ILogger Logger => _logger;
|
||||
|
||||
/// <summary>Test hook: keys with a live entry.</summary>
|
||||
internal int EntryCount { get { lock (_lock) return _entries.Count; } }
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
|
||||
|
||||
/// <summary>PCCC file-type codes for the protected typed logical read/write commands (0xA2/0xAA).
|
||||
/// N (0x89) and F (0x8A) are verified against the golden captures; the rest follow the PCCC
|
||||
/// data-table file-type table.</summary>
|
||||
/// <para>Wire evidence (final review I3), all against the <c>ab_server</c> simulator, never a real PLC:
|
||||
/// N (0x89) — byte parity with libplctag captures (SLC 500 read + write, MicroLogix read, PLC-5 read) and
|
||||
/// native ab_server round trips (N7:0 read on all three profiles, N7:5 write on SLC 500); F (0x8A) — one
|
||||
/// SLC 500 F8 read capture only (no native write on a wire).</para>
|
||||
/// <para>S, B, T, C, R, ST, A, L, O and I are encoded from the PCCC data-table file-type table and are
|
||||
/// UNVERIFIED natively — no capture and no simulator test. That includes S:0, the default probe address,
|
||||
/// and the T/C/R sub-element (PRE/ACC/LEN/POS) word offsets.</para></summary>
|
||||
public static class PcccFileType
|
||||
{
|
||||
/// <summary>Returns the file-type code for a file letter (case-insensitive), or null when unknown.</summary>
|
||||
|
||||
+1
@@ -18,5 +18,6 @@
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Host.Tests"/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -34,7 +34,8 @@ public sealed class AbCipDriverOptions
|
||||
|
||||
/// <summary>
|
||||
/// Which transport stack the driver, its Test Connect probe and the CLI use. <c>null</c> (the
|
||||
/// <c>transport</c> key absent) means "use the default", which is <see cref="AbCipTransport.Native"/>;
|
||||
/// <c>transport</c> key absent) means "use the default": the node-level
|
||||
/// <c>Drivers:AbCip:DefaultTransport</c> appsetting, which itself defaults to <see cref="AbCipTransport.Native"/>;
|
||||
/// an explicit value always wins. The effective transport is resolved in one place,
|
||||
/// <c>AbCipTransportResolver</c>. Set <see cref="AbCipTransport.Libplctag"/> to fall back.
|
||||
/// <para>Changing this takes effect when the driver instance is RECREATED, not on
|
||||
|
||||
@@ -6,9 +6,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// </summary>
|
||||
public enum AbCipTransport
|
||||
{
|
||||
/// <summary>The libplctag-backed transport. The default.</summary>
|
||||
/// <summary>The libplctag-backed transport. The fallback; selected explicitly or via the node-level <c>Drivers:AbCip:DefaultTransport</c>.</summary>
|
||||
Libplctag,
|
||||
|
||||
/// <summary>The native managed EtherNet/IP + CIP transport (shared Driver.Ab.Transport stack).</summary>
|
||||
/// <summary>The native managed EtherNet/IP + CIP transport (shared Driver.Ab.Transport stack). The default.</summary>
|
||||
Native,
|
||||
}
|
||||
|
||||
@@ -141,19 +141,23 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <param name="enumeratorFactory">Optional factory for enumerating tags; when null the resolved transport's is used.</param>
|
||||
/// <param name="templateReaderFactory">Optional factory for reading UDT templates; when null the resolved transport's is used.</param>
|
||||
/// <param name="logger">Optional logger; uses null logger if not provided.</param>
|
||||
/// <param name="defaultTransport">The node-level default for an absent <c>transport</c> key
|
||||
/// (<c>Drivers:AbCip:DefaultTransport</c>); null means the compiled <see cref="AbCipTransportResolver.Default"/>.</param>
|
||||
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
|
||||
IAbCipTagFactory? tagFactory = null,
|
||||
IAbCipTagEnumeratorFactory? enumeratorFactory = null,
|
||||
IAbCipTemplateReaderFactory? templateReaderFactory = null,
|
||||
ILogger<AbCipDriver>? logger = null)
|
||||
ILogger<AbCipDriver>? logger = null,
|
||||
AbCipTransport? defaultTransport = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
_driverInstanceId = driverInstanceId;
|
||||
// Chosen once here and held across ReinitializeAsync (Shutdown+Initialize): a changed
|
||||
// Transport in a reinit config is inert; a deploy recreates the instance instead.
|
||||
_transport = AbCipTransportResolver.Resolve(options);
|
||||
_transportDescription = AbCipTransportResolver.Describe(options.Transport);
|
||||
var nodeDefault = defaultTransport ?? AbCipTransportResolver.Default;
|
||||
_transport = AbCipTransportResolver.Resolve(options, nodeDefault);
|
||||
_transportDescription = AbCipTransportResolver.Describe(options.Transport, nodeDefault);
|
||||
var defaults = AbCipTransportFactories.For(_transport);
|
||||
_tagFactory = tagFactory ?? defaults.Tags;
|
||||
_enumeratorFactory = enumeratorFactory ?? defaults.Enumerators;
|
||||
@@ -182,7 +186,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
/// <summary>The effective transport resolved at construction (see <see cref="AbCipTransportResolver"/>).
|
||||
/// Describes the OPTION only: injected factories (tests) are used regardless.</summary>
|
||||
internal AbCipTransport EffectiveTransport => _transport;
|
||||
public AbCipTransport EffectiveTransport => _transport;
|
||||
|
||||
// The transport chosen at construction (see the ctor); logged at every init so an operator can tell
|
||||
// from the node log which stack serves this instance.
|
||||
@@ -191,6 +195,18 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// Configured-vs-effective rendering of _transport for the init log, e.g. "Native (default)".
|
||||
private readonly string _transportDescription;
|
||||
|
||||
// Tags whose type-mismatch Warning has already been logged (final review I4): a mis-authored tag is
|
||||
// polled forever, so the Warning is once per tag per driver instance, not once per poll.
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _typeMismatchWarned =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Logs a type-mismatch Warning the first time <paramref name="tagName"/> reports one on this instance.</summary>
|
||||
private void WarnTypeMismatchOnce(string tagName, string message, params object?[] args)
|
||||
{
|
||||
if (_typeMismatchWarned.TryAdd(tagName, 0))
|
||||
_logger.LogWarning(message, args);
|
||||
}
|
||||
|
||||
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
|
||||
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
|
||||
|
||||
@@ -803,6 +819,19 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
await runtime.ReadAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status == (int)AbStatus.TypeMismatch)
|
||||
{
|
||||
// Final review I4: the controller ANSWERED with a type that disagrees with the authored
|
||||
// one — a deterministic config error, not a comms fault. Keep the handle (evicting would
|
||||
// re-create it — and, for a sole lease holder, re-Forward-Open — on every poll), leave
|
||||
// health alone, warn once per tag.
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
|
||||
WarnTypeMismatchOnce(def.Name,
|
||||
"AbCip read of {Tag} on device {Device}: the controller's type does not match the authored " +
|
||||
"{DataType}; reporting BadTypeMismatch until the tag's dataType is corrected (logged once per tag)",
|
||||
reference, def.DeviceHostAddress, def.DataType);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
if (status != 0)
|
||||
{
|
||||
// Evict the stale handle so the next call re-creates it.
|
||||
@@ -830,11 +859,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
&& ExpectedScalarWidth(def.DataType) is { } width
|
||||
&& runtime.GetSize() is var size && size >= 0 && size != width)
|
||||
{
|
||||
// Same class as the native TypeMismatch above (final review I4): deterministic, device
|
||||
// answered — no Degraded, warn once per tag.
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
|
||||
_logger.LogWarning(
|
||||
"AbCip read of {Tag} on device {Device} returned {Size} bytes but its authored type {DataType} is {Width} bytes wide; check the tag's dataType",
|
||||
WarnTypeMismatchOnce(def.Name,
|
||||
"AbCip read of {Tag} on device {Device} returned {Size} bytes but its authored type {DataType} is {Width} bytes wide; check the tag's dataType (logged once per tag)",
|
||||
reference, def.DeviceHostAddress, size, def.DataType, width);
|
||||
return ReadOutcome.Degraded($"type mismatch reading {reference}: {size} bytes for {def.DataType}");
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
// Review I-1 — an array tag (the EXPLICIT IsArray flag) decodes the whole buffer into an
|
||||
// element-typed CLR array (int[]/float[]/bool[]/string[]…), INCLUDING a 1-element array
|
||||
@@ -861,6 +892,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
|
||||
{
|
||||
// Final review I4: deterministic, keep the handle, no health signal, warn once per tag.
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
|
||||
WarnTypeMismatchOnce(def.Name, "AbCip read of {Tag} on device {Device} refused: {Reason} (logged once per tag)",
|
||||
reference, def.DeviceHostAddress, ate.Message);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Transport exception — evict so the next read creates a fresh handle. An AbTransportException
|
||||
@@ -907,6 +946,16 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
await runtime.ReadAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status == (int)AbStatus.TypeMismatch)
|
||||
{
|
||||
// Final review I4: deterministic — keep the handle, no health signal, warn once.
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadTypeMismatch);
|
||||
WarnTypeMismatchOnce(parent.Name,
|
||||
"AbCip whole-UDT read of {Parent} on device {Device}: the controller's type does not match " +
|
||||
"the authored type; {MemberCount} members report BadTypeMismatch (logged once per tag)",
|
||||
group.ParentName, parent.DeviceHostAddress, group.Members.Count);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
if (status != 0)
|
||||
{
|
||||
EvictRuntime(device, parent.Name);
|
||||
@@ -940,6 +989,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNotSupported);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
|
||||
{
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadTypeMismatch);
|
||||
WarnTypeMismatchOnce(parent.Name,
|
||||
"AbCip whole-UDT read of {Parent} on device {Device} refused: {Reason} (logged once per tag)",
|
||||
group.ParentName, parent.DeviceHostAddress, ate.Message);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
EvictRuntime(device, parent.Name);
|
||||
@@ -1023,7 +1080,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
opLock.Release();
|
||||
}
|
||||
|
||||
if (status != 0)
|
||||
if (status == (int)AbStatus.TypeMismatch)
|
||||
{
|
||||
// Final review I4: deterministic — keep the handle, leave health, warn once per tag.
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
||||
WarnTypeMismatchOnce(def.Name,
|
||||
"AbCip write to {Tag} on device {Device} refused: the controller's type does not match the authored {DataType} (logged once per tag)",
|
||||
w.FullReference, def.DeviceHostAddress, def.DataType);
|
||||
}
|
||||
else if (status != 0)
|
||||
{
|
||||
EvictRuntime(device, def.Name);
|
||||
results[i] = new WriteResult(AbCipStatusMapper.MapStatus(status));
|
||||
@@ -1085,6 +1150,16 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
"AbCip write refused (not supported) for tag {Tag} on device {Device}",
|
||||
w.FullReference, def.DeviceHostAddress);
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
|
||||
{
|
||||
// Final review I4: the controller's type disagrees with the authored one (native type gate, or
|
||||
// CIP 0xFF/0x2107 from the controller) — deterministic: BadTypeMismatch, keep the handle, leave
|
||||
// health alone, warn once per tag. Retrying cannot succeed until the config is corrected.
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
||||
WarnTypeMismatchOnce(def.Name,
|
||||
"AbCip write to {Tag} on device {Device} refused: {Reason} (logged once per tag)",
|
||||
w.FullReference, def.DeviceHostAddress, ate.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Transport / wire error — evict so the next write creates a fresh handle.
|
||||
|
||||
@@ -24,10 +24,14 @@ public static class AbCipDriverFactoryExtensions
|
||||
/// </summary>
|
||||
/// <param name="registry">The driver factory registry to register with.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for driver instances.</param>
|
||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
||||
/// <param name="defaultTransport">The node-level transport for drivers whose config has no
|
||||
/// <c>transport</c> key (<c>Drivers:AbCip:DefaultTransport</c>, final review I1). An explicit
|
||||
/// per-driver key still wins.</param>
|
||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null,
|
||||
AbCipTransport defaultTransport = AbCipTransportResolver.Default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
|
||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, defaultTransport));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -43,18 +47,21 @@ public static class AbCipDriverFactoryExtensions
|
||||
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
|
||||
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for the driver instance.</param>
|
||||
/// <param name="defaultTransport">The node-level default for an absent <c>transport</c> key.</param>
|
||||
/// <returns>A configured <see cref="AbCipDriver"/> instance.</returns>
|
||||
internal static AbCipDriver CreateInstance(
|
||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory,
|
||||
AbCipTransport defaultTransport = AbCipTransportResolver.Default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||
var options = ParseOptions(driverInstanceId, driverConfigJson);
|
||||
return new AbCipDriver(options, driverInstanceId, logger: loggerFactory?.CreateLogger<AbCipDriver>());
|
||||
return new AbCipDriver(options, driverInstanceId, logger: loggerFactory?.CreateLogger<AbCipDriver>(),
|
||||
defaultTransport: defaultTransport);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialise an AB CIP driver-config JSON document into <see cref="AbCipDriverOptions"/>.
|
||||
/// Shared by <see cref="CreateInstance(string, string, ILoggerFactory?)"/> (first construction) and
|
||||
/// Shared by <see cref="CreateInstance(string, string, ILoggerFactory?, AbCipTransport)"/> (first construction) and
|
||||
/// <see cref="AbCipDriver.InitializeAsync"/> / <see cref="AbCipDriver.ReinitializeAsync"/>
|
||||
/// so a reinitialize with a changed config JSON (new device, new tag, changed timeout)
|
||||
/// actually takes effect rather than being silently discarded.
|
||||
@@ -155,7 +162,7 @@ public static class AbCipDriverFactoryExtensions
|
||||
internal sealed class AbCipDriverConfigDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the transport (<c>Libplctag</c> or <c>Native</c>, case-insensitive); absent means Libplctag.
|
||||
/// Gets or sets the transport (<c>Libplctag</c> or <c>Native</c>, case-insensitive); absent means the node default.
|
||||
/// </summary>
|
||||
public string? Transport { get; init; }
|
||||
|
||||
|
||||
@@ -34,11 +34,22 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
};
|
||||
|
||||
private readonly IAbCipTagFactory? _tagFactory;
|
||||
private readonly AbCipTransport _defaultTransport;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="AbCipDriverProbe"/> class.</summary>
|
||||
/// <param name="tagFactory">Factory for the probe's tag runtime; when null the config's
|
||||
/// <c>Transport</c> selects the native or libplctag-backed one.</param>
|
||||
public AbCipDriverProbe(IAbCipTagFactory? tagFactory = null) => _tagFactory = tagFactory;
|
||||
/// <param name="defaultTransport">The node-level default for a config with no <c>transport</c> key — the
|
||||
/// SAME value the node's driver factory uses, so Test Connect probes the stack the driver would run.</param>
|
||||
public AbCipDriverProbe(IAbCipTagFactory? tagFactory = null,
|
||||
AbCipTransport defaultTransport = AbCipTransportResolver.Default)
|
||||
{
|
||||
_tagFactory = tagFactory;
|
||||
_defaultTransport = defaultTransport;
|
||||
}
|
||||
|
||||
/// <summary>The node-level default this probe resolves an absent key to (wiring tests).</summary>
|
||||
public AbCipTransport DefaultTransport => _defaultTransport;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "AbCip";
|
||||
@@ -123,7 +134,7 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
ConnectionSize: firstDevice?.ConnectionSize ?? profile.DefaultConnectionSize,
|
||||
UseConnectedMessaging: profile.SupportsConnectedMessaging);
|
||||
|
||||
var rt = (_tagFactory ?? AbCipTransportFactories.For(AbCipTransportResolver.Resolve(opts)).Tags).Create(p);
|
||||
var rt = (_tagFactory ?? AbCipTransportFactories.For(AbCipTransportResolver.Resolve(opts, _defaultTransport)).Tags).Create(p);
|
||||
try
|
||||
{
|
||||
await rt.InitializeAsync(cts.Token);
|
||||
|
||||
@@ -5,32 +5,61 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// <see cref="AbCipDriverOptions.Transport"/>. The driver constructor, the Test Connect probe and the
|
||||
/// CLI (through the driver) all resolve through here, so they cannot disagree about what an absent
|
||||
/// <c>transport</c> key means.
|
||||
/// <para>Precedence (final review I1): an explicit per-driver <c>transport</c> key wins; otherwise the
|
||||
/// node-level default (<c>Drivers:AbCip:DefaultTransport</c>, read in the Host composition root and passed
|
||||
/// in as <c>nodeDefault</c>); otherwise the compiled <see cref="Default"/>.</para>
|
||||
/// </summary>
|
||||
public static class AbCipTransportResolver
|
||||
{
|
||||
/// <summary>The transport an absent / null <c>transport</c> key selects.</summary>
|
||||
/// <summary>The compiled default: the transport an absent key selects when no node default is configured.</summary>
|
||||
public const AbCipTransport Default = AbCipTransport.Native;
|
||||
|
||||
/// <summary>Returns the effective transport: an explicit value always wins; null means <see cref="Default"/>.</summary>
|
||||
/// <summary>Returns the effective transport: an explicit value always wins; null means <paramref name="nodeDefault"/>.</summary>
|
||||
/// <param name="configured">The configured transport, or null for "use the default".</param>
|
||||
/// <param name="nodeDefault">The node-level default for drivers with no key (the compiled <see cref="Default"/> unless pinned).</param>
|
||||
/// <returns>The transport the driver runs on.</returns>
|
||||
public static AbCipTransport Resolve(AbCipTransport? configured) => configured ?? Default;
|
||||
public static AbCipTransport Resolve(AbCipTransport? configured, AbCipTransport nodeDefault = Default) =>
|
||||
configured ?? nodeDefault;
|
||||
|
||||
/// <summary>Returns the effective transport for <paramref name="options"/>.</summary>
|
||||
/// <param name="options">The driver options.</param>
|
||||
/// <param name="nodeDefault">The node-level default for drivers with no key.</param>
|
||||
/// <returns>The transport the driver runs on.</returns>
|
||||
public static AbCipTransport Resolve(AbCipDriverOptions options)
|
||||
public static AbCipTransport Resolve(AbCipDriverOptions options, AbCipTransport nodeDefault = Default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return Resolve(options.Transport);
|
||||
return Resolve(options.Transport, nodeDefault);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a transport name the way the driver-config <c>transport</c> key and the node-level default
|
||||
/// are parsed: a defined enum NAME, case-insensitive. Numeric strings ("0", "7") and the flags form
|
||||
/// ("Native,Libplctag") are rejected — <c>Enum.TryParse</c> alone accepts both.
|
||||
/// </summary>
|
||||
/// <param name="raw">The raw value.</param>
|
||||
/// <param name="transport">The parsed transport.</param>
|
||||
/// <returns>True when <paramref name="raw"/> names a transport.</returns>
|
||||
public static bool TryParse(string? raw, out AbCipTransport transport)
|
||||
{
|
||||
transport = Default;
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
// Exact name match only: Enum.TryParse would also accept "1" and the flags form "Native,Libplctag".
|
||||
var name = Enum.GetNames<AbCipTransport>()
|
||||
.FirstOrDefault(n => string.Equals(n, raw.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
return name is not null && Enum.TryParse(name, out transport);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operator-facing description of the configured-vs-effective transport for the init log line:
|
||||
/// <c>Native (default)</c> when the key was absent, the bare transport name when it was explicit.
|
||||
/// <c>Native (default)</c> when the key was absent and the compiled default applies,
|
||||
/// <c>Libplctag (node default)</c> when the key was absent and the node pinned a different default,
|
||||
/// <c>Native (configured)</c> when the key was explicit.
|
||||
/// </summary>
|
||||
/// <param name="configured">The configured transport, or null.</param>
|
||||
/// <param name="nodeDefault">The node-level default.</param>
|
||||
/// <returns>A short description.</returns>
|
||||
public static string Describe(AbCipTransport? configured) =>
|
||||
configured is null ? $"{Resolve(configured)} (default)" : $"{configured} (configured)";
|
||||
public static string Describe(AbCipTransport? configured, AbCipTransport nodeDefault = Default) =>
|
||||
configured is not null ? $"{configured} (configured)"
|
||||
: nodeDefault == Default ? $"{nodeDefault} (default)"
|
||||
: $"{nodeDefault} (node default)";
|
||||
}
|
||||
|
||||
@@ -31,7 +31,11 @@ internal static class LibplctagOrphanRelease
|
||||
private const BindingFlags Private = BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
private static readonly FieldInfo? WrapperField = typeof(Tag).GetField("_tag", Private);
|
||||
private static readonly FieldInfo? HandleField = WrapperField?.FieldType.GetField("nativeTagHandle", Private);
|
||||
// Final review M2: the field must also still be an int. A wrapper that retyped it (IntPtr, long) would make
|
||||
// the cast in HandleOf throw; treating it as unreadable instead degrades to the rooting fallback ("keep
|
||||
// alive"), which the pin test reports.
|
||||
private static readonly FieldInfo? HandleField =
|
||||
WrapperField?.FieldType.GetField("nativeTagHandle", Private) is { } f && f.FieldType == typeof(int) ? f : null;
|
||||
|
||||
/// <summary>Tags whose handle could not be read: kept reachable so their callback delegate is never collected.</summary>
|
||||
private static readonly ConcurrentBag<Tag> Rooted = [];
|
||||
@@ -50,9 +54,20 @@ internal static class LibplctagOrphanRelease
|
||||
/// <summary>Orphaned tags rooted for the process lifetime because their handle could not be read.</summary>
|
||||
internal static int RootedCount => Rooted.Count;
|
||||
|
||||
/// <summary>The native handle the wrapper holds (0 when none was ever created, or unreadable).</summary>
|
||||
internal static int HandleOf(Tag tag) =>
|
||||
HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper ? (int)HandleField.GetValue(wrapper)! : 0;
|
||||
/// <summary>The native handle the wrapper holds (0 when none was ever created, or unreadable). Never throws:
|
||||
/// callers run it inside a catch block, where an exception would replace the failure being handled.</summary>
|
||||
internal static int HandleOf(Tag tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
return HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper
|
||||
&& HandleField.GetValue(wrapper) is int handle ? handle : 0;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears the wrapper's handle after a destroy, so a repeat release is a no-op (review r1 M2).</summary>
|
||||
private static void ClearHandle(Tag tag)
|
||||
|
||||
@@ -7,9 +7,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// connection from <see cref="DeviceSessionPool.Shared"/>, so all tags on one device share one CIP
|
||||
/// connection and one request scheduler. Stateless; safe to share across devices.
|
||||
/// </summary>
|
||||
internal sealed class NativeAbCipTagFactory : IAbCipTagFactory
|
||||
internal sealed class NativeAbCipTagFactory(DeviceSessionPool? pool = null) : IAbCipTagFactory
|
||||
{
|
||||
// Tests pass a private pool (and a fake target); production uses the process-wide one.
|
||||
private readonly DeviceSessionPool _pool = pool ?? DeviceSessionPool.Shared;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) =>
|
||||
new NativeAbCipTagRuntime(createParams, DeviceSessionPool.Shared);
|
||||
new NativeAbCipTagRuntime(createParams, _pool);
|
||||
}
|
||||
|
||||
@@ -27,9 +27,9 @@ public sealed class AbLegacyDriverOptions
|
||||
|
||||
/// <summary>
|
||||
/// Which transport stack the driver, its Test Connect probe and the CLI use. <c>null</c> (the
|
||||
/// <c>transport</c> key absent) means "use the default": <see cref="AbLegacyTransport.Native"/>, UNLESS
|
||||
/// any configured device is a PLC-5 or LogixPccc (limited native support), in which case the whole
|
||||
/// driver resolves to <see cref="AbLegacyTransport.Libplctag"/>. An explicit value always wins. The
|
||||
/// <c>transport</c> key absent) means "use the default": <see cref="AbLegacyTransport.Libplctag"/> for
|
||||
/// every family. <see cref="AbLegacyTransport.Native"/> is opt-in (final review I3: on a wire it is proven
|
||||
/// only for N-file reads/writes and one F8 read). An explicit value always wins. The
|
||||
/// effective transport is resolved in one place, <c>AbLegacyTransportResolver</c>.
|
||||
/// <para>Changing this takes effect when the driver instance is RECREATED, not on
|
||||
/// <c>ReinitializeAsync</c>: the tag factory is chosen once in the constructor and held across
|
||||
|
||||
@@ -72,7 +72,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
// Chosen once here and held across ReinitializeAsync (Shutdown+Initialize): a changed
|
||||
// Transport in a reinit config is inert; a deploy recreates the instance instead.
|
||||
_transport = AbLegacyTransportResolver.Resolve(options);
|
||||
_transportDescription = AbLegacyTransportResolver.Describe(options.Transport, _transport);
|
||||
_transportDescription = AbLegacyTransportResolver.Describe(options.Transport);
|
||||
_tagFactory = tagFactory ?? (_transport == AbLegacyTransport.Native
|
||||
? new NativeAbLegacyTagFactory()
|
||||
: new LibplctagLegacyTagFactory());
|
||||
@@ -103,7 +103,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
// from the node log which stack serves this instance.
|
||||
private readonly AbLegacyTransport _transport;
|
||||
|
||||
// Configured-vs-effective rendering of _transport for the init log, e.g. "Native (default)".
|
||||
// Configured-vs-effective rendering of _transport for the init log, e.g. "Libplctag (default)".
|
||||
private readonly string _transportDescription;
|
||||
|
||||
// Set once the explicit-Native-on-a-limited-family Warning has been logged for this instance.
|
||||
@@ -157,8 +157,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
if (HasConfigBody(driverConfigJson))
|
||||
_options = AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
|
||||
|
||||
// The resolver only defaults a PLC-5 / LogixPccc driver to libplctag; an operator may still pick
|
||||
// Native explicitly. Allowed, but the limitation is client-visible, so name it ONCE per instance —
|
||||
// Native is opt-in for every family (the default is libplctag); an operator may pick it explicitly
|
||||
// even for PLC-5 / LogixPccc. Allowed, but the limitation is client-visible, so name it ONCE per instance —
|
||||
// not on every init, which includes every comms-loss reconnect retry (Task 22 review r1 M4).
|
||||
if (!_limitedFamilyWarned
|
||||
&& _transport == AbLegacyTransport.Native
|
||||
|
||||
@@ -97,7 +97,7 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
|
||||
},
|
||||
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
|
||||
// Absent ⇒ null ("use the default", resolved by AbLegacyTransportResolver against the devices);
|
||||
// Absent ⇒ null ("use the default" = Libplctag, resolved by AbLegacyTransportResolver);
|
||||
// a bad value still fails exactly as any other bad enum field does.
|
||||
Transport = string.IsNullOrWhiteSpace(dto.Transport)
|
||||
? null
|
||||
@@ -153,7 +153,7 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
public int? TimeoutMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the transport (<c>Libplctag</c> or <c>Native</c>, case-insensitive); absent means Libplctag.
|
||||
/// Gets or sets the transport (<c>Libplctag</c> or <c>Native</c>, case-insensitive); absent means Libplctag (the default).
|
||||
/// </summary>
|
||||
public string? Transport { get; init; }
|
||||
|
||||
|
||||
@@ -4,16 +4,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// The ONE place an AbLegacy driver's effective transport is decided from its configured
|
||||
/// <see cref="AbLegacyDriverOptions.Transport"/> and its devices. The driver constructor, the Test
|
||||
/// Connect probe and the CLI (through the driver) all resolve through here.
|
||||
/// <para>An absent key means <see cref="AbLegacyTransport.Native"/> — UNLESS any configured device is a
|
||||
/// <see cref="AbLegacyPlcFamily.Plc5"/> or <see cref="AbLegacyPlcFamily.LogixPccc"/>, whose native support
|
||||
/// is limited (PLC-5: N-file reads only; LogixPccc: framing unverified). The tag factory is chosen once
|
||||
/// per DRIVER, not per device, so one such device resolves the whole driver to
|
||||
/// <see cref="AbLegacyTransport.Libplctag"/>. An explicit value always wins (an operator choice).</para>
|
||||
/// <para>The probe resolves from the config it is GIVEN. The AdminUI merges only the probed device into
|
||||
/// that config, so in a mixed-family defaulted driver Test Connect on an SLC device can resolve Native
|
||||
/// while the deployed driver resolves Libplctag (documented in docs/drivers/AbLegacy.md).</para>
|
||||
/// <see cref="AbLegacyDriverOptions.Transport"/>. The driver constructor, the Test Connect probe and the
|
||||
/// CLI (through the driver) all resolve through here.
|
||||
/// <para>An absent key means <see cref="Default"/> = <see cref="AbLegacyTransport.Libplctag"/> for EVERY
|
||||
/// family (final review I3): on a wire the native PCCC path is proven only for N-file reads and writes
|
||||
/// and one F8 read, so Native stays opt-in. An explicit value always wins (an operator choice). An
|
||||
/// explicit Native on a PLC-5 / LogixPccc device is allowed but logs a once-per-instance Warning
|
||||
/// (<see cref="NativeLimitedFamilies"/>).</para>
|
||||
/// </summary>
|
||||
public static class AbLegacyTransportResolver
|
||||
{
|
||||
@@ -32,16 +29,13 @@ public static class AbLegacyTransportResolver
|
||||
return [.. devices.Select(d => d.PlcFamily).Where(IsNativeLimited).Distinct()];
|
||||
}
|
||||
|
||||
/// <summary>Returns the effective transport for a configured value and device set.</summary>
|
||||
/// <summary>The transport an absent / null <c>transport</c> key selects (all families).</summary>
|
||||
public const AbLegacyTransport Default = AbLegacyTransport.Libplctag;
|
||||
|
||||
/// <summary>Returns the effective transport: an explicit value always wins; null means <see cref="Default"/>.</summary>
|
||||
/// <param name="configured">The configured transport, or null for "use the default".</param>
|
||||
/// <param name="devices">The configured devices.</param>
|
||||
/// <returns>The transport the driver runs on.</returns>
|
||||
public static AbLegacyTransport Resolve(AbLegacyTransport? configured, IEnumerable<AbLegacyDeviceOptions> devices)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(devices);
|
||||
if (configured is { } explicitValue) return explicitValue;
|
||||
return devices.Any(d => IsNativeLimited(d.PlcFamily)) ? AbLegacyTransport.Libplctag : AbLegacyTransport.Native;
|
||||
}
|
||||
public static AbLegacyTransport Resolve(AbLegacyTransport? configured) => configured ?? Default;
|
||||
|
||||
/// <summary>Returns the effective transport for <paramref name="options"/>.</summary>
|
||||
/// <param name="options">The driver options.</param>
|
||||
@@ -49,19 +43,15 @@ public static class AbLegacyTransportResolver
|
||||
public static AbLegacyTransport Resolve(AbLegacyDriverOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return Resolve(options.Transport, options.Devices);
|
||||
return Resolve(options.Transport);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operator-facing description of the configured-vs-effective transport for the init log line, e.g.
|
||||
/// <c>Native (default)</c>, <c>Libplctag (default; PLC-5/LogixPccc device present)</c> or
|
||||
/// <c>Native (configured)</c>.
|
||||
/// Operator-facing description of the configured-vs-effective transport for the init log line:
|
||||
/// <c>Libplctag (default)</c> when the key was absent, <c>Native (configured)</c> when explicit.
|
||||
/// </summary>
|
||||
/// <param name="configured">The configured transport, or null.</param>
|
||||
/// <param name="effective">The effective transport.</param>
|
||||
/// <returns>A short description.</returns>
|
||||
public static string Describe(AbLegacyTransport? configured, AbLegacyTransport effective) =>
|
||||
configured is not null ? $"{effective} (configured)"
|
||||
: effective == AbLegacyTransport.Libplctag ? $"{effective} (default; PLC-5/LogixPccc device present)"
|
||||
: $"{effective} (default)";
|
||||
public static string Describe(AbLegacyTransport? configured) =>
|
||||
configured is null ? $"{Resolve(configured)} (default)" : $"{configured} (configured)";
|
||||
}
|
||||
|
||||
@@ -31,7 +31,11 @@ internal static class LibplctagOrphanRelease
|
||||
private const BindingFlags Private = BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
private static readonly FieldInfo? WrapperField = typeof(Tag).GetField("_tag", Private);
|
||||
private static readonly FieldInfo? HandleField = WrapperField?.FieldType.GetField("nativeTagHandle", Private);
|
||||
// Final review M2: the field must also still be an int. A wrapper that retyped it (IntPtr, long) would make
|
||||
// the cast in HandleOf throw; treating it as unreadable instead degrades to the rooting fallback ("keep
|
||||
// alive"), which the pin test reports.
|
||||
private static readonly FieldInfo? HandleField =
|
||||
WrapperField?.FieldType.GetField("nativeTagHandle", Private) is { } f && f.FieldType == typeof(int) ? f : null;
|
||||
|
||||
/// <summary>Tags whose handle could not be read: kept reachable so their callback delegate is never collected.</summary>
|
||||
private static readonly ConcurrentBag<Tag> Rooted = [];
|
||||
@@ -50,9 +54,20 @@ internal static class LibplctagOrphanRelease
|
||||
/// <summary>Orphaned tags rooted for the process lifetime because their handle could not be read.</summary>
|
||||
internal static int RootedCount => Rooted.Count;
|
||||
|
||||
/// <summary>The native handle the wrapper holds (0 when none was ever created, or unreadable).</summary>
|
||||
internal static int HandleOf(Tag tag) =>
|
||||
HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper ? (int)HandleField.GetValue(wrapper)! : 0;
|
||||
/// <summary>The native handle the wrapper holds (0 when none was ever created, or unreadable). Never throws:
|
||||
/// callers run it inside a catch block, where an exception would replace the failure being handled.</summary>
|
||||
internal static int HandleOf(Tag tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
return HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper
|
||||
&& HandleField.GetValue(wrapper) is int handle ? handle : 0;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears the wrapper's handle after a destroy, so a repeat release is a no-op (review r1 M2).</summary>
|
||||
private static void ClearHandle(Tag tag)
|
||||
|
||||
@@ -99,7 +99,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
// is inherently scalar — arrayCount does not apply.
|
||||
if (bitIndex is int bit && type == TwinCATDataType.Bool)
|
||||
{
|
||||
var parent = StripBitSuffix(symbolPath);
|
||||
var parent = TwinCATBitAccess.ParentPath(symbolPath);
|
||||
var parentResult = await _client.ReadValueAsync(parent, typeof(uint), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (parentResult.ErrorCode != AdsErrorCode.NoError)
|
||||
@@ -107,31 +107,29 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
return (ExtractBit(parentResult.Value, bit), TwinCATStatusMapper.Good);
|
||||
}
|
||||
|
||||
// Array read — ADS reads array symbols natively into a typed managed array. We ask
|
||||
// the SDK for the element-CLR-type's 1-D array (e.g. int[]); the .NET binding
|
||||
// marshals the whole array in one read. The returned value is already the boxed
|
||||
// element-typed array (int[] / float[] / bool[] / string[] / …), so the runtime's
|
||||
// OPC UA layer sees a proper 1-D array value. ASSUMPTION (no live ADS here):
|
||||
// AdsClient.ReadValueAsync(symbol, elementType.MakeArrayType(), ct) returns the
|
||||
// typed array — this matches Beckhoff's documented generic ReadValue<T[]> surface
|
||||
// (InfoSys tcadsnetref / SumRead samples). Verified only against the fake client.
|
||||
if (arrayCount is int count && count > 0)
|
||||
{
|
||||
var elementType = MapToClrType(type);
|
||||
var arrayType = elementType.MakeArrayType();
|
||||
var arrResult = await _client.ReadValueAsync(symbolPath, arrayType, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (arrResult.ErrorCode != AdsErrorCode.NoError)
|
||||
return (null, MapAndSignal((uint)arrResult.ErrorCode));
|
||||
return (arrResult.Value, TwinCATStatusMapper.Good);
|
||||
}
|
||||
|
||||
// Scalar read: check the declared type against the PLC symbol first. The symbol is
|
||||
// Resolve the PLC symbol first so the declared type can be checked against it. The symbol is
|
||||
// resolved through the Beckhoff symbol cache (the same lookup ReadValueAsync makes
|
||||
// internally), so this costs no extra round trip after the first read.
|
||||
var (shape, symbol, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
|
||||
var (shape, array, symbol, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (symbolStatus != TwinCATStatusMapper.Good) return (null, symbolStatus);
|
||||
|
||||
// Array read: raw bytes at the layout TwinCATTypeGuard.CheckArray plans from the RESOLVED
|
||||
// array type, decoded by TwinCATArrayCodec — the same decode a native array notification
|
||||
// runs, so poll and notification publish identical arrays. The Beckhoff typed array read this
|
||||
// replaced failed for most arrays on a live TC 2.11 target: the CLR element type had to match
|
||||
// exactly (ARRAY OF TIME as uint[] threw), a multi-dimensional array needed a rank-N CLR
|
||||
// array, and a member array was sliced by its under-reported symbol size.
|
||||
if (arrayCount is int count)
|
||||
{
|
||||
if (TwinCATTypeGuard.CheckArray(type, count, shape, array, out var layout) is not null)
|
||||
return (null, TwinCATStatusMapper.BadTypeMismatch);
|
||||
var (arrayBytes, arrayStatus) = await ReadRawAsync(symbolPath, symbol!, layout.ByteLength, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return arrayStatus == TwinCATStatusMapper.Good
|
||||
? (TwinCATArrayCodec.Decode(type, arrayBytes, layout, StringsAreUtf8()), TwinCATStatusMapper.Good)
|
||||
: (null, arrayStatus);
|
||||
}
|
||||
if (TwinCATTypeGuard.CheckRead(type, shape) is not null)
|
||||
return (null, TwinCATStatusMapper.BadTypeMismatch);
|
||||
|
||||
@@ -170,21 +168,34 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
/// <see cref="TwinCATTypeGuard"/> compares a declared type against. Uses the Beckhoff symbol
|
||||
/// cache (lookup on), so repeat calls are local.
|
||||
/// </summary>
|
||||
private async Task<(TwinCATPlcSymbolShape shape, IAdsSymbol? symbol, uint status)> DescribeSymbolAsync(
|
||||
string symbolPath, CancellationToken cancellationToken)
|
||||
private async Task<(TwinCATPlcSymbolShape shape, TwinCATPlcArrayShape? array, IAdsSymbol? symbol, uint status)>
|
||||
DescribeSymbolAsync(string symbolPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _client.ReadSymbolAsync(symbolPath, cancellationToken).ConfigureAwait(false);
|
||||
if (result.ErrorCode != AdsErrorCode.NoError)
|
||||
return (default, null, MapAndSignal((uint)result.ErrorCode));
|
||||
return (default, null, null, MapAndSignal((uint)result.ErrorCode));
|
||||
if (result.Value is not { } symbol)
|
||||
return (default, null, TwinCATStatusMapper.BadNodeIdUnknown);
|
||||
return (default, null, null, TwinCATStatusMapper.BadNodeIdUnknown);
|
||||
|
||||
// Resolve aliases (T_MaxString → STRING(255)) so the guard sees the base type, and take the
|
||||
// resolved type's byte size — a TwinCAT 2 member symbol's own ByteSize is under-reported.
|
||||
var dataType = symbol.DataType?.Resolve();
|
||||
var shape = TwinCATPlcSymbolShape.Classify(
|
||||
dataType?.Name ?? symbol.TypeName, dataType?.Category, symbol.ByteSize, dataType?.ByteSize, symbol.TypeName);
|
||||
return (shape, symbol, TwinCATStatusMapper.Good);
|
||||
return (shape, DescribeArray(dataType), symbol, TwinCATStatusMapper.Good);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The array facts <see cref="TwinCATTypeGuard.CheckArray"/> plans from: every dimension's element
|
||||
/// count, the resolved array size, and the alias-resolved element type. Null for a non-array.
|
||||
/// </summary>
|
||||
private static TwinCATPlcArrayShape? DescribeArray(IDataType? dataType)
|
||||
{
|
||||
if (dataType is not IArrayType arrayType || arrayType.Dimensions is not { } dims) return null;
|
||||
var element = arrayType.ElementType?.Resolve();
|
||||
var elementShape = TwinCATPlcSymbolShape.Classify(
|
||||
element?.Name ?? arrayType.ElementTypeName, element?.Category, element?.ByteSize ?? 0);
|
||||
return new TwinCATPlcArrayShape([.. dims.Select(d => d.ElementCount)], dataType.ByteSize, elementShape);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -246,15 +257,6 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
? TwinCATStringCodec.DecodeWString(bytes)
|
||||
: TwinCATStringCodec.DecodeString(bytes, utf8);
|
||||
|
||||
private static string StripBitSuffix(string symbolPath)
|
||||
{
|
||||
var lastDot = symbolPath.LastIndexOf('.');
|
||||
if (lastDot < 0) return symbolPath;
|
||||
return int.TryParse(symbolPath.AsSpan(lastDot + 1), out _)
|
||||
? symbolPath[..lastDot]
|
||||
: symbolPath;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<uint> WriteValueAsync(
|
||||
string symbolPath,
|
||||
@@ -297,6 +299,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
int? arrayCount,
|
||||
TimeSpan cycleTime,
|
||||
int maxDelayMs,
|
||||
Action<string, object?> onChange,
|
||||
@@ -305,24 +308,59 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
var clrType = MapToClrType(type);
|
||||
int[]? anyTypeArgs = null;
|
||||
var stringsUtf8 = false;
|
||||
if (bitIndex is null || type != TwinCATDataType.Bool)
|
||||
TwinCATArrayLayout? arrayLayout = null;
|
||||
var registerPath = symbolPath;
|
||||
if (bitIndex is int bit && type == TwinCATDataType.Bool)
|
||||
{
|
||||
// ADS has no symbol for WORD.N (live on TC 2.11: DeviceSymbolNotFound for .ADSSTATE_RUN.0,
|
||||
// which took the whole subscribe call down). Register the PARENT word as raw bytes at its
|
||||
// resolved width and read the bit in OnAdsNotificationEx — the read path's model.
|
||||
registerPath = TwinCATBitAccess.ParentPath(symbolPath);
|
||||
var (parent, _, _, parentStatus) = await DescribeSymbolAsync(registerPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (parentStatus != TwinCATStatusMapper.Good)
|
||||
throw new InvalidOperationException(
|
||||
$"Resolving {registerPath} for a notification failed with status 0x{parentStatus:X8}");
|
||||
if (TwinCATBitAccess.CheckParent(bit, parent) is { } bitReason)
|
||||
throw new TwinCATTypeMismatchException(symbolPath, bitReason);
|
||||
clrType = typeof(byte[]);
|
||||
anyTypeArgs = [parent.ByteSize];
|
||||
}
|
||||
else
|
||||
{
|
||||
// A notification carries raw bytes decoded as the DECLARED type, so a declared type that
|
||||
// cannot decode the PLC symbol (width, float/integer or string kind differ) would publish
|
||||
// wrong values at Good. Refuse it here; the driver isolates the one tag to Bad.
|
||||
var (shape, _, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
|
||||
var (shape, array, _, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (symbolStatus != TwinCATStatusMapper.Good)
|
||||
throw new InvalidOperationException(
|
||||
$"Resolving {symbolPath} for a notification failed with status 0x{symbolStatus:X8}");
|
||||
if (TwinCATTypeGuard.CheckNotification(type, shape) is { } reason)
|
||||
|
||||
if (arrayCount is int count)
|
||||
{
|
||||
// An array registers the whole planned layout (element stride × authored length, from the
|
||||
// RESOLVED array type) as raw bytes and decodes it in OnAdsNotificationEx with the same
|
||||
// TwinCATArrayCodec a poll read uses — so both paths publish the identical array.
|
||||
if (TwinCATTypeGuard.CheckArray(type, count, shape, array, out var layout) is { } arrayReason)
|
||||
throw new TwinCATTypeMismatchException(symbolPath, arrayReason);
|
||||
if (TwinCATTypeGuard.CheckNotificationSize(layout) is { } sizeReason)
|
||||
throw new TwinCATNotificationRefusedException(symbolPath, sizeReason, TwinCATStatusMapper.BadOutOfRange);
|
||||
clrType = typeof(byte[]);
|
||||
anyTypeArgs = [layout.ByteLength];
|
||||
stringsUtf8 = StringsAreUtf8();
|
||||
arrayLayout = layout;
|
||||
}
|
||||
else if (TwinCATTypeGuard.CheckNotification(type, shape) is { } reason)
|
||||
{
|
||||
throw new TwinCATTypeMismatchException(symbolPath, reason);
|
||||
}
|
||||
|
||||
// A string has no fixed width: the "any" type string needs an explicit length (the
|
||||
// Beckhoff marshaller throws ArgumentNullException without one), and a STRING(n) is n+1
|
||||
// bytes with n chosen per declaration. Register the PLC's own byte width as raw bytes
|
||||
// and decode them in OnAdsNotificationEx with the same rules as a read.
|
||||
if (type is TwinCATDataType.String or TwinCATDataType.WString)
|
||||
if (arrayLayout is null && type is TwinCATDataType.String or TwinCATDataType.WString)
|
||||
{
|
||||
clrType = typeof(byte[]);
|
||||
anyTypeArgs = [shape.ByteSize];
|
||||
@@ -337,23 +375,24 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
// with the handle as part of the event args so we use the handle as the correlation
|
||||
// key into _notifications.
|
||||
var result = await _client.AddDeviceNotificationExAsync(
|
||||
symbolPath, settings, userData: null, clrType, anyTypeArgs, cancellationToken)
|
||||
registerPath, settings, userData: null, clrType, anyTypeArgs, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (result.ErrorCode != AdsErrorCode.NoError)
|
||||
throw new InvalidOperationException(
|
||||
$"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {symbolPath}");
|
||||
$"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {registerPath}");
|
||||
|
||||
var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle)
|
||||
{
|
||||
StringsUtf8 = stringsUtf8,
|
||||
ArrayLayout = arrayLayout,
|
||||
};
|
||||
_notifications[result.Handle] = reg;
|
||||
return reg;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs on the <see cref="AdsClient"/> AMS router thread. Does the cheap bit-extraction
|
||||
/// decode then enqueues — no driver logic, no consumer callbacks — so a slow consumer
|
||||
/// Runs on the <see cref="AdsClient"/> AMS router thread. Does the cheap decode (bit
|
||||
/// extraction, string, array) then enqueues — no driver logic, no consumer callbacks — so a slow consumer
|
||||
/// can never stall ADS notification delivery for the rest of the process.
|
||||
/// Drops the notification (DropWrite) if the queue is saturated.
|
||||
/// </summary>
|
||||
@@ -361,8 +400,16 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
{
|
||||
if (!_notifications.TryGetValue(args.Handle, out var reg)) return;
|
||||
var value = args.Value;
|
||||
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool)
|
||||
value = ExtractBit(value, bit);
|
||||
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool)
|
||||
{
|
||||
if (value is not byte[] word || bit / 8 >= word.Length) return;
|
||||
value = TwinCATBitAccess.ReadBit(word, bit);
|
||||
}
|
||||
else if (reg.ArrayLayout is { } layout && value is byte[] arrayBytes)
|
||||
{
|
||||
try { value = TwinCATArrayCodec.Decode(reg.Type, arrayBytes, layout, reg.StringsUtf8); }
|
||||
catch (ArgumentException) { return; } // a short buffer is never published as a short array
|
||||
}
|
||||
else if (value is byte[] raw && reg.Type is TwinCATDataType.String or TwinCATDataType.WString)
|
||||
value = DecodeString(reg.Type, raw, reg.StringsUtf8);
|
||||
_notificationQueue.Writer.TryWrite(new PendingNotification(reg, value));
|
||||
@@ -615,6 +662,11 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
/// </summary>
|
||||
public bool StringsUtf8 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw-bytes layout of an array notification, decoded by <see cref="TwinCATArrayCodec"/>; null for a scalar.
|
||||
/// </summary>
|
||||
public TwinCATArrayLayout? ArrayLayout { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the notification subscription.
|
||||
/// </summary>
|
||||
|
||||
@@ -41,9 +41,11 @@ public interface ITwinCATClient : IDisposable
|
||||
/// <param name="symbolPath">The ADS symbol path.</param>
|
||||
/// <param name="type">The target data type.</param>
|
||||
/// <param name="bitIndex">Optional bit index for bit extraction within a word.</param>
|
||||
/// <param name="arrayCount">When non-null, read a 1-D array of this many <paramref name="type"/>
|
||||
/// elements; the boxed value is the element-typed CLR array (<c>int[]</c> / <c>float[]</c> /
|
||||
/// <c>bool[]</c> / <c>string[]</c> / …). When null, read a scalar (Phase 4c).</param>
|
||||
/// <param name="arrayCount">When non-null, read an array of this many <paramref name="type"/>
|
||||
/// elements (a multi-dimensional PLC array flattened in memory order; a count shorter than the PLC
|
||||
/// array reads its leading sub-range); the boxed value is the element-typed CLR array
|
||||
/// (<c>int[]</c> / <c>float[]</c> / <c>bool[]</c> / <c>string[]</c> / …) decoded by
|
||||
/// <see cref="TwinCATArrayCodec"/>. When null, read a scalar.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the read operation.</param>
|
||||
/// <returns>The boxed value read (or <c>null</c>) together with the mapped OPC UA status.</returns>
|
||||
Task<(object? value, uint status)> ReadValueAsync(
|
||||
@@ -87,6 +89,10 @@ public interface ITwinCATClient : IDisposable
|
||||
/// <param name="symbolPath">ADS symbol path (e.g. <c>MAIN.bStart</c>).</param>
|
||||
/// <param name="type">Declared type; drives the native layout + callback value boxing.</param>
|
||||
/// <param name="bitIndex">For BOOL-within-word tags — the bit to extract from the parent word.</param>
|
||||
/// <param name="arrayCount">When non-null (and no <paramref name="bitIndex"/>), register the whole
|
||||
/// array: <paramref name="arrayCount"/> elements of <paramref name="type"/> as raw bytes, decoded per
|
||||
/// notification into the same element-typed CLR array <see cref="ReadValueAsync"/> returns
|
||||
/// (<see cref="TwinCATArrayCodec"/>). Null = a scalar.</param>
|
||||
/// <param name="cycleTime">Minimum interval between change notifications (native-floor depends on target).</param>
|
||||
/// <param name="maxDelayMs">Maximum batching delay in milliseconds — TwinCAT may coalesce
|
||||
/// notifications up to this delay before pushing them. <c>0</c> = no batching, push
|
||||
@@ -94,13 +100,18 @@ public interface ITwinCATClient : IDisposable
|
||||
/// <param name="onChange">Invoked with <c>(symbolPath, boxedValue)</c> per notification.</param>
|
||||
/// <param name="cancellationToken">Cancels the initial registration; does not tear down an established notification.</param>
|
||||
/// <returns>A handle whose disposal tears the registered notification down.</returns>
|
||||
/// <exception cref="TwinCATTypeMismatchException">The declared <paramref name="type"/> cannot decode
|
||||
/// the PLC symbol (width, float/integer or string kind differ) — the caller isolates that one tag
|
||||
/// to Bad rather than registering a notification that would publish wrong bytes at Good.</exception>
|
||||
/// <exception cref="TwinCATNotificationRefusedException">The notification is refused for this one tag
|
||||
/// — the declared <paramref name="type"/> / <paramref name="arrayCount"/> cannot decode the PLC
|
||||
/// symbol (<see cref="TwinCATTypeMismatchException"/>: width, float/integer or string kind differ,
|
||||
/// not an array, too long), or the array exceeds
|
||||
/// <see cref="TwinCATTypeGuard.MaxNotificationArrayBytes"/>. The caller isolates that one tag to the
|
||||
/// exception's status rather than registering a notification that would publish wrong bytes at
|
||||
/// Good.</exception>
|
||||
Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
int? arrayCount,
|
||||
TimeSpan cycleTime,
|
||||
int maxDelayMs,
|
||||
Action<string, object?> onChange,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a PLC array's raw bytes — laid out by <see cref="TwinCATTypeGuard.CheckArray"/> — into the
|
||||
/// element-typed CLR array an array tag publishes (<c>bool[]</c>, <c>short[]</c>, <c>float[]</c>,
|
||||
/// <c>string[]</c>, …). Both the poll read and the native notification decode here, so the two paths
|
||||
/// publish identical values, element type and length by construction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The driver reads arrays as raw bytes rather than through the Beckhoff typed array read, which a live
|
||||
/// TwinCAT 2.11 target showed failing for most real arrays (2026-10-07, Elma cleaner Z33856): the CLR
|
||||
/// element type had to match the PLC's exactly (<c>ARRAY OF TIME</c> as <c>uint[]</c> threw), a
|
||||
/// multi-dimensional array demanded a rank-N CLR array, and a dotted MEMBER array was sliced by the
|
||||
/// under-reported symbol size (<c>ArgumentOutOfRangeException</c>). Elements are little-endian (every
|
||||
/// TwinCAT target is x86/x64/ARM-LE); a STRING / WSTRING element is decoded by
|
||||
/// <see cref="TwinCATStringCodec"/> within its fixed stride, so each ends at its own first NUL.
|
||||
/// </remarks>
|
||||
internal static class TwinCATArrayCodec
|
||||
{
|
||||
/// <summary>Decodes <paramref name="layout"/>.ElementCount elements of <paramref name="declared"/>.</summary>
|
||||
/// <param name="declared">The tag's declared element type.</param>
|
||||
/// <param name="bytes">The PLC bytes from the array's first element (at least <see cref="TwinCATArrayLayout.ByteLength"/>).</param>
|
||||
/// <param name="layout">The planned layout.</param>
|
||||
/// <param name="utf8">True when the target reports UTF-8 STRING data (else Windows-1252).</param>
|
||||
/// <returns>The element-typed CLR array.</returns>
|
||||
/// <exception cref="ArgumentException">The buffer is shorter than the layout.</exception>
|
||||
public static Array Decode(TwinCATDataType declared, ReadOnlySpan<byte> bytes, TwinCATArrayLayout layout, bool utf8)
|
||||
{
|
||||
if (layout.ElementCount < 0 || layout.Stride <= 0 || bytes.Length < layout.ByteLength)
|
||||
throw new ArgumentException(
|
||||
$"An array buffer of {bytes.Length} B cannot hold {layout.ElementCount} × {layout.Stride} B elements",
|
||||
nameof(bytes));
|
||||
|
||||
var n = layout.ElementCount;
|
||||
var stride = layout.Stride;
|
||||
switch (declared)
|
||||
{
|
||||
case TwinCATDataType.Bool:
|
||||
{
|
||||
var a = new bool[n];
|
||||
for (var i = 0; i < n; i++) a[i] = bytes[i * stride] != 0;
|
||||
return a;
|
||||
}
|
||||
case TwinCATDataType.SInt:
|
||||
{
|
||||
var a = new sbyte[n];
|
||||
for (var i = 0; i < n; i++) a[i] = unchecked((sbyte)bytes[i * stride]);
|
||||
return a;
|
||||
}
|
||||
case TwinCATDataType.USInt:
|
||||
{
|
||||
var a = new byte[n];
|
||||
for (var i = 0; i < n; i++) a[i] = bytes[i * stride];
|
||||
return a;
|
||||
}
|
||||
case TwinCATDataType.Int:
|
||||
{
|
||||
var a = new short[n];
|
||||
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt16LittleEndian(bytes[(i * stride)..]);
|
||||
return a;
|
||||
}
|
||||
case TwinCATDataType.UInt:
|
||||
{
|
||||
var a = new ushort[n];
|
||||
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt16LittleEndian(bytes[(i * stride)..]);
|
||||
return a;
|
||||
}
|
||||
case TwinCATDataType.DInt:
|
||||
{
|
||||
var a = new int[n];
|
||||
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt32LittleEndian(bytes[(i * stride)..]);
|
||||
return a;
|
||||
}
|
||||
case TwinCATDataType.UDInt or TwinCATDataType.Time or TwinCATDataType.Date
|
||||
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay:
|
||||
{
|
||||
// TIME / DATE / DT / TOD publish their raw 32-bit count — the same CLR shape (uint) a
|
||||
// scalar of those types publishes.
|
||||
var a = new uint[n];
|
||||
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt32LittleEndian(bytes[(i * stride)..]);
|
||||
return a;
|
||||
}
|
||||
case TwinCATDataType.LInt:
|
||||
{
|
||||
var a = new long[n];
|
||||
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt64LittleEndian(bytes[(i * stride)..]);
|
||||
return a;
|
||||
}
|
||||
case TwinCATDataType.ULInt:
|
||||
{
|
||||
var a = new ulong[n];
|
||||
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt64LittleEndian(bytes[(i * stride)..]);
|
||||
return a;
|
||||
}
|
||||
case TwinCATDataType.Real:
|
||||
{
|
||||
var a = new float[n];
|
||||
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadSingleLittleEndian(bytes[(i * stride)..]);
|
||||
return a;
|
||||
}
|
||||
case TwinCATDataType.LReal:
|
||||
{
|
||||
var a = new double[n];
|
||||
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadDoubleLittleEndian(bytes[(i * stride)..]);
|
||||
return a;
|
||||
}
|
||||
case TwinCATDataType.String:
|
||||
{
|
||||
var a = new string[n];
|
||||
for (var i = 0; i < n; i++) a[i] = TwinCATStringCodec.DecodeString(bytes.Slice(i * stride, stride), utf8);
|
||||
return a;
|
||||
}
|
||||
case TwinCATDataType.WString:
|
||||
{
|
||||
var a = new string[n];
|
||||
for (var i = 0; i < n; i++) a[i] = TwinCATStringCodec.DecodeWString(bytes.Slice(i * stride, stride));
|
||||
return a;
|
||||
}
|
||||
default:
|
||||
throw new NotSupportedException($"TwinCATDataType {declared} has no array element decoding");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// Serves a bit-indexed BOOL tag (<c>MAIN.Flags.3</c>, <c>.ADSSTATE_RUN.0</c>) from its PARENT word.
|
||||
/// ADS has no symbol for <c>WORD.N</c> — live on a TwinCAT 2.11 target a notification registered on
|
||||
/// <c>.ADSSTATE_RUN.0</c> failed with <c>DeviceSymbolNotFound</c> — so a native notification registers
|
||||
/// the parent as raw bytes at its resolved width and reads the bit here, exactly as a read already
|
||||
/// reads the parent word and extracts the bit.
|
||||
/// </summary>
|
||||
internal static class TwinCATBitAccess
|
||||
{
|
||||
/// <summary>The parent word's path: the bit path without its trailing <c>.N</c>.</summary>
|
||||
/// <param name="symbolPath">The bit path (<c>MAIN.Flags.3</c>).</param>
|
||||
/// <returns>The parent path, or <paramref name="symbolPath"/> when it has no numeric last segment.</returns>
|
||||
public static string ParentPath(string symbolPath)
|
||||
{
|
||||
var lastDot = symbolPath.LastIndexOf('.');
|
||||
if (lastDot < 0) return symbolPath;
|
||||
return int.TryParse(symbolPath.AsSpan(lastDot + 1), out _) ? symbolPath[..lastDot] : symbolPath;
|
||||
}
|
||||
|
||||
/// <summary>Checks that <paramref name="bit"/> addresses a bit of an integer parent word.</summary>
|
||||
/// <param name="bit">The bit index.</param>
|
||||
/// <param name="parent">The parent symbol's shape.</param>
|
||||
/// <returns><c>null</c> when addressable; otherwise the reason.</returns>
|
||||
public static string? CheckParent(int bit, TwinCATPlcSymbolShape parent)
|
||||
{
|
||||
if (parent.Kind != TwinCATPlcKind.Scalar || parent.IsFloat)
|
||||
return $"bit {bit} of {parent.TypeName} ({parent.ByteSize} B) — only an integer word has addressable bits";
|
||||
return bit >= 0 && bit < parent.ByteSize * 8
|
||||
? null
|
||||
: $"bit {bit} is outside the {parent.ByteSize * 8}-bit parent {parent.TypeName}";
|
||||
}
|
||||
|
||||
/// <summary>Reads bit <paramref name="bit"/> of a little-endian parent word.</summary>
|
||||
/// <param name="parent">The parent word's raw bytes.</param>
|
||||
/// <param name="bit">The bit index (0 = least significant bit of the first byte).</param>
|
||||
/// <returns>The bit's value.</returns>
|
||||
public static bool ReadBit(ReadOnlySpan<byte> parent, int bit) =>
|
||||
(parent[bit / 8] & (1 << (bit % 8))) != 0;
|
||||
}
|
||||
@@ -263,10 +263,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||
// An array-typed tag (def.ArrayLength != null) drives a native 1-D ADS array read;
|
||||
// the boxed result is an element-typed CLR array. Scalar tags pass null
|
||||
// (scalar path) — bit-indexed BOOLs are inherently scalar so the client ignores
|
||||
// arrayCount when a bitIndex is present (Phase 4c).
|
||||
// An array-typed tag (def.ArrayLength != null) reads the array as raw bytes and decodes
|
||||
// it with TwinCATArrayCodec — the same decode a native array notification runs — into an
|
||||
// element-typed CLR array. Scalar tags pass null (scalar path) — bit-indexed BOOLs are
|
||||
// inherently scalar so the client ignores arrayCount when a bitIndex is present.
|
||||
var (value, status) = await client.ReadValueAsync(
|
||||
symbolName, def.DataType, parsed?.BitIndex, def.ArrayLength, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -279,7 +279,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
if (status == TwinCATStatusMapper.BadTypeMismatch)
|
||||
_logger.LogWarning(
|
||||
"TwinCAT driver '{DriverInstanceId}' refused '{Reference}': declared {DataType} " +
|
||||
"cannot decode the PLC symbol '{Symbol}' (string/numeric kind mismatch or a reference/container)",
|
||||
"cannot decode the PLC symbol '{Symbol}' (string/numeric kind mismatch, array element type " +
|
||||
"or length mismatch, or a reference/container)",
|
||||
_driverInstanceId, reference, def.DataType, symbolName);
|
||||
else
|
||||
_logger.LogWarning(
|
||||
@@ -531,7 +532,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
// client swap unchanged — replay re-registers the SAME intent onto the new client.
|
||||
var reg = new NativeRegistration(
|
||||
Interlocked.Increment(ref _nextRegId), device, reference, symbolName,
|
||||
def.DataType, bitIndex, publishingInterval,
|
||||
def.DataType, bitIndex, def.ArrayLength, publishingInterval,
|
||||
onChange: (_, value) => OnDataChange?.Invoke(this,
|
||||
new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
|
||||
value, TwinCATStatusMapper.Good, DateTime.UtcNow, DateTime.UtcNow))),
|
||||
@@ -545,17 +546,19 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
{
|
||||
await RegisterNotificationAsync(device, reg, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (TwinCATTypeMismatchException mismatch)
|
||||
catch (TwinCATNotificationRefusedException refused)
|
||||
{
|
||||
// The declared type cannot decode this PLC symbol (ignitionoee TwinCAT-002/-008):
|
||||
// isolate THIS tag to Bad — one WARN, one BadTypeMismatch snapshot — and keep the
|
||||
// rest of the subscription. Its intent stays on the device with no live handle,
|
||||
// so the probe-tick retry re-checks it after a PLC program change.
|
||||
// The declared type / array length cannot decode this PLC symbol (ignitionoee
|
||||
// TwinCAT-002/-008), or the array is over the native-notification size cap: isolate
|
||||
// THIS tag to Bad — one WARN, one snapshot carrying the refusal's status
|
||||
// (BadTypeMismatch / BadOutOfRange) — and keep the rest of the subscription. Its
|
||||
// intent stays on the device with no live handle, so the probe-tick retry re-checks
|
||||
// it after a PLC program change.
|
||||
_logger.LogWarning(
|
||||
"TwinCAT driver '{DriverInstanceId}' refused native notification for '{Reference}': {Reason}",
|
||||
_driverInstanceId, reference, mismatch.Message);
|
||||
_driverInstanceId, reference, refused.Message);
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
|
||||
null, TwinCATStatusMapper.BadTypeMismatch, null, DateTime.UtcNow)));
|
||||
null, refused.StatusCode, null, DateTime.UtcNow)));
|
||||
}
|
||||
registrations.Add(reg);
|
||||
}
|
||||
@@ -594,10 +597,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
try
|
||||
{
|
||||
newHandle = await client.AddNotificationAsync(
|
||||
reg.SymbolName, reg.DataType, reg.BitIndex, reg.Interval,
|
||||
reg.SymbolName, reg.DataType, reg.BitIndex, reg.ArrayLength, reg.Interval,
|
||||
_options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (TwinCATTypeMismatchException)
|
||||
catch (TwinCATNotificationRefusedException)
|
||||
{
|
||||
// Keep the intent with no live handle (a "dead" registration) so the probe-tick
|
||||
// retry re-checks it and UnsubscribeAsync still removes it; the caller isolates it.
|
||||
@@ -659,6 +662,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
public TwinCATDataType DataType { get; }
|
||||
/// <summary>Bit index for a BOOL-within-word symbol; null otherwise.</summary>
|
||||
public int? BitIndex { get; }
|
||||
/// <summary>Authored array length — the whole array is registered and published; null for a scalar.</summary>
|
||||
public int? ArrayLength { get; }
|
||||
/// <summary>Requested notification interval.</summary>
|
||||
public TimeSpan Interval { get; }
|
||||
/// <summary>Value-change callback (closes over the owning subscription handle + reference).</summary>
|
||||
@@ -687,11 +692,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
/// <param name="symbolName">ADS symbol name (resolved from the symbol path).</param>
|
||||
/// <param name="dataType">Declared data type.</param>
|
||||
/// <param name="bitIndex">Bit index for a BOOL-within-word symbol; null otherwise.</param>
|
||||
/// <param name="arrayLength">Authored array length for an array tag; null for a scalar.</param>
|
||||
/// <param name="interval">Requested notification interval.</param>
|
||||
/// <param name="onChange">Value-change callback (closes over the owning subscription handle + reference).</param>
|
||||
/// <param name="onUnavailable">Bad-quality callback fired when the handle goes dead (05/STAB-16).</param>
|
||||
public NativeRegistration(long id, DeviceState device, string reference, string symbolName,
|
||||
TwinCATDataType dataType, int? bitIndex, TimeSpan interval, Action<string, object?> onChange,
|
||||
TwinCATDataType dataType, int? bitIndex, int? arrayLength, TimeSpan interval, Action<string, object?> onChange,
|
||||
Action onUnavailable)
|
||||
{
|
||||
Id = id;
|
||||
@@ -700,6 +706,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
SymbolName = symbolName;
|
||||
DataType = dataType;
|
||||
BitIndex = bitIndex;
|
||||
ArrayLength = arrayLength;
|
||||
Interval = interval;
|
||||
OnChange = onChange;
|
||||
OnUnavailable = onUnavailable;
|
||||
@@ -969,7 +976,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
try
|
||||
{
|
||||
var newHandle = await client.AddNotificationAsync(
|
||||
reg.SymbolName, reg.DataType, reg.BitIndex, reg.Interval,
|
||||
reg.SymbolName, reg.DataType, reg.BitIndex, reg.ArrayLength, reg.Interval,
|
||||
_options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false);
|
||||
var old = reg.SwapHandle(newHandle);
|
||||
try { old?.Dispose(); } catch { /* dead handle from the disposed client */ }
|
||||
@@ -1034,7 +1041,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
try
|
||||
{
|
||||
var newHandle = await client.AddNotificationAsync(
|
||||
reg.SymbolName, reg.DataType, reg.BitIndex, reg.Interval,
|
||||
reg.SymbolName, reg.DataType, reg.BitIndex, reg.ArrayLength, reg.Interval,
|
||||
_options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false);
|
||||
reg.SwapHandle(newHandle);
|
||||
// STAB-17 ownership re-check — see ReplayNativeRegistrationsAsync.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by <see cref="ITwinCATClient.AddNotificationAsync"/> when the client refuses to register a
|
||||
/// native notification for one tag — its declared type cannot decode the PLC symbol
|
||||
/// (<see cref="TwinCATTypeMismatchException"/>), or an array is too large for a native notification.
|
||||
/// The driver isolates that one tag to <see cref="StatusCode"/> (one WARN) and keeps the rest of the
|
||||
/// subscription; the intent stays registered so the probe-tick retry re-checks it.
|
||||
/// </summary>
|
||||
internal class TwinCATNotificationRefusedException : Exception
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="TwinCATNotificationRefusedException"/> class.</summary>
|
||||
/// <param name="symbolPath">The ADS symbol the tag names.</param>
|
||||
/// <param name="reason">The one-line reason.</param>
|
||||
/// <param name="statusCode">The OPC UA status the refused tag publishes.</param>
|
||||
public TwinCATNotificationRefusedException(string symbolPath, string reason, uint statusCode)
|
||||
: base($"TwinCAT symbol '{symbolPath}': {reason}")
|
||||
{
|
||||
SymbolPath = symbolPath;
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
|
||||
/// <summary>Gets the ADS symbol the refused tag names.</summary>
|
||||
public string SymbolPath { get; }
|
||||
|
||||
/// <summary>Gets the OPC UA status the refused tag publishes.</summary>
|
||||
public uint StatusCode { get; }
|
||||
}
|
||||
@@ -24,7 +24,9 @@ internal enum TwinCATPlcKind
|
||||
/// <param name="ByteSize">The symbol's byte size as the PLC reports it.</param>
|
||||
/// <param name="IsFloat">True for a REAL / LREAL scalar.</param>
|
||||
/// <param name="TypeName">The (alias-resolved) PLC type name, for log messages.</param>
|
||||
internal readonly record struct TwinCATPlcSymbolShape(TwinCATPlcKind Kind, int ByteSize, bool IsFloat, string TypeName)
|
||||
/// <param name="IsArray">True for an ARRAY (a <see cref="TwinCATPlcKind.NonScalar"/> with array category or name).</param>
|
||||
internal readonly record struct TwinCATPlcSymbolShape(
|
||||
TwinCATPlcKind Kind, int ByteSize, bool IsFloat, string TypeName, bool IsArray = false)
|
||||
{
|
||||
private static readonly Regex StringTypeName =
|
||||
new(@"^(W?)STRING(\s*\(\s*\d+\s*\))?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
@@ -71,13 +73,39 @@ internal readonly record struct TwinCATPlcSymbolShape(TwinCATPlcKind Kind, int B
|
||||
|| name.StartsWith("POINTER TO", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.StartsWith("REFERENCE TO", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.Contains("VAR_IN_OUT", StringComparison.OrdinalIgnoreCase))
|
||||
return new(TwinCATPlcKind.NonScalar, byteSize, false, name);
|
||||
return new(TwinCATPlcKind.NonScalar, byteSize, false, name,
|
||||
IsArray: category == DataTypeCategory.Array || name.StartsWith("ARRAY", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var mapped = AdsTwinCATClient.MapSymbolTypeName(name);
|
||||
return new(TwinCATPlcKind.Scalar, byteSize, mapped is TwinCATDataType.Real or TwinCATDataType.LReal, name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The facts about a PLC ARRAY symbol <see cref="TwinCATTypeGuard.CheckArray"/> plans a layout from.</summary>
|
||||
/// <param name="DimensionLengths">The element count of each dimension (lower bounds play no part: the
|
||||
/// driver publishes a 0-based, flattened array in PLC memory order).</param>
|
||||
/// <param name="ByteSize">The array's byte size, from its RESOLVED data type (a TwinCAT 2 member symbol
|
||||
/// under-reports its own size).</param>
|
||||
/// <param name="Element">The (alias-resolved) element type, classified like a scalar symbol.</param>
|
||||
internal readonly record struct TwinCATPlcArrayShape(IReadOnlyList<int> DimensionLengths, int ByteSize, TwinCATPlcSymbolShape Element)
|
||||
{
|
||||
/// <summary>Gets the total element count across every dimension (0 when any dimension is empty).</summary>
|
||||
public long ElementCount => DimensionLengths.Aggregate(1L, (total, length) => total * Math.Max(0, length));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How an array tag is read from the PLC's raw bytes: <paramref name="ElementCount"/> elements at a
|
||||
/// fixed <paramref name="Stride"/>, starting at the array's first byte.
|
||||
/// </summary>
|
||||
/// <param name="ElementCount">The number of elements published (the authored <c>ArrayLength</c>).</param>
|
||||
/// <param name="Stride">The bytes per element: the element type's width, or n+1 / 2(n+1) for a
|
||||
/// <c>STRING(n)</c> / <c>WSTRING(n)</c>.</param>
|
||||
internal readonly record struct TwinCATArrayLayout(int ElementCount, int Stride)
|
||||
{
|
||||
/// <summary>Gets the number of bytes the read or notification covers.</summary>
|
||||
public int ByteLength => ElementCount * Stride;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether a tag's DECLARED <see cref="TwinCATDataType"/> may decode the PLC symbol it names —
|
||||
/// the port of the ignitionoee TwinCAT driver's extra-type reconciliation (TwinCAT-002, -008). A
|
||||
@@ -90,11 +118,14 @@ internal readonly record struct TwinCATPlcSymbolShape(TwinCATPlcKind Kind, int B
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Read</b> (<see cref="CheckRead"/>) goes through the Beckhoff symbol cache, which
|
||||
/// decodes by the PLC's OWN type and converts the value to the declared CLR type — a numeric
|
||||
/// pair is value-converted, never reinterpreted. Only string-ness can go wrong there: a
|
||||
/// numeric tag over a PLC string got a <c>string</c> object back at Good.</item>
|
||||
/// pair is value-converted, never reinterpreted. Only string-ness can go wrong there (a
|
||||
/// numeric tag over a PLC string got a <c>string</c> object back at Good), plus a scalar over
|
||||
/// an ARRAY, which the read cannot convert at all.</item>
|
||||
/// <item><b>Notification</b> (<see cref="CheckNotification"/>) registers raw bytes decoded as the
|
||||
/// DECLARED type, so a width mismatch truncates/over-reads and a same-width float/integer clash
|
||||
/// reinterprets the bit pattern — both at Good.</item>
|
||||
/// <item><b>Array tags</b> (<see cref="CheckArray"/>) are read AND notified as raw bytes, so the
|
||||
/// element gets the notification rules on both paths.</item>
|
||||
/// </list>
|
||||
/// String-ness is a KIND (TwinCAT-008): a STRING/WSTRING tag matches only a PLC string of the SAME
|
||||
/// kind, at any declared length (0.19.2). A same-width, same-kind numeric divergence
|
||||
@@ -117,6 +148,9 @@ internal static class TwinCATTypeGuard
|
||||
: $"declared {declared} but the PLC symbol is {plc.TypeName} ({plc.ByteSize} B) — a string tag " +
|
||||
$"matches only a PLC {declaredKind.Value.ToString().ToUpperInvariant()} of any length";
|
||||
}
|
||||
if (plc.IsArray)
|
||||
return $"declared scalar {declared} but the PLC symbol is the array {plc.TypeName} ({plc.ByteSize} B) — " +
|
||||
"author it as an array tag (isArray + arrayLength) or address one element (Arr[i])";
|
||||
return plc.Kind is TwinCATPlcKind.String or TwinCATPlcKind.WString
|
||||
? $"declared {declared} but the PLC symbol is the string {plc.TypeName} — declare it String/WString"
|
||||
: null;
|
||||
@@ -144,6 +178,72 @@ internal static class TwinCATTypeGuard
|
||||
: $"declared {declared} but the PLC symbol is {plc.TypeName} — float/integer kinds differ";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The largest array a native notification registers, in bytes: 128 KiB — the largest registration
|
||||
/// proven on a real target (a TwinCAT 2.11 <c>ARRAY [0..65535] OF UINT</c>, live 2026-10-07; ADS
|
||||
/// itself documents no notification size limit). The PLC compares the whole buffer every cycle for
|
||||
/// an on-change notification, and every change is copied through the router and the driver's
|
||||
/// notification queue, so a bigger array is refused rather than registered blind.
|
||||
/// </summary>
|
||||
public const int MaxNotificationArrayBytes = 128 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Checks an ARRAY tag — its declared element type and authored <c>ArrayLength</c> — against a PLC
|
||||
/// array, and plans the raw-bytes layout both the poll read and the native notification decode
|
||||
/// with <see cref="TwinCATArrayCodec"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <list type="bullet">
|
||||
/// <item>The element is checked like a scalar notification (<see cref="CheckNotification"/>):
|
||||
/// the bytes are decoded as the DECLARED element type, so width, float/integer and string kind
|
||||
/// must agree. Struct / FB / pointer / nested-array (jagged) elements are refused.</item>
|
||||
/// <item>A multi-dimensional array is published flattened in PLC memory order (the last index
|
||||
/// varies fastest); its <c>ArrayLength</c> is the total element count. Lower bounds play no
|
||||
/// part — the published index is 0-based.</item>
|
||||
/// <item>An authored <c>ArrayLength</c> SHORTER than the PLC array reads the leading sub-range
|
||||
/// (the first N elements, N × stride bytes from the array start); a LONGER one is refused, since
|
||||
/// it would read past the array.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
/// <param name="declared">The tag's declared element type.</param>
|
||||
/// <param name="declaredLength">The tag's authored <c>ArrayLength</c>.</param>
|
||||
/// <param name="symbol">The PLC symbol's shape (decides a non-array symbol; names it in messages).</param>
|
||||
/// <param name="array">The PLC array's shape, or null when the symbol is not an array.</param>
|
||||
/// <param name="layout">The planned layout when compatible; otherwise default.</param>
|
||||
/// <returns><c>null</c> when compatible; otherwise the reason.</returns>
|
||||
public static string? CheckArray(TwinCATDataType declared, int declaredLength, TwinCATPlcSymbolShape symbol,
|
||||
TwinCATPlcArrayShape? array, out TwinCATArrayLayout layout)
|
||||
{
|
||||
layout = default;
|
||||
if (declaredLength <= 0)
|
||||
return $"declared an array of {declaredLength} {declared} elements — an array tag needs arrayLength >= 1";
|
||||
if (array is not { } plc || symbol.TypeName.Contains("VAR_IN_OUT", StringComparison.OrdinalIgnoreCase))
|
||||
return $"declared an array of {declaredLength} {declared} but the PLC symbol is {symbol.TypeName} " +
|
||||
$"({symbol.ByteSize} B) — not an array";
|
||||
|
||||
if (CheckNotification(declared, plc.Element) is { } elementReason)
|
||||
return $"{symbol.TypeName}: element {elementReason}";
|
||||
|
||||
var stride = plc.Element.ByteSize;
|
||||
var count = plc.ElementCount;
|
||||
if (stride <= 0 || count <= 0 || count * stride != plc.ByteSize)
|
||||
return $"{symbol.TypeName} is {plc.ByteSize} B, not {count} × {stride} B elements — not a packed array";
|
||||
if (declaredLength > count)
|
||||
return $"declared arrayLength {declaredLength} but the PLC array {symbol.TypeName} has {count} elements";
|
||||
|
||||
layout = new TwinCATArrayLayout(declaredLength, stride);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Checks a planned array layout against <see cref="MaxNotificationArrayBytes"/>.</summary>
|
||||
/// <param name="layout">The planned layout.</param>
|
||||
/// <returns><c>null</c> when it fits; otherwise the reason.</returns>
|
||||
public static string? CheckNotificationSize(TwinCATArrayLayout layout) =>
|
||||
layout.ByteLength <= MaxNotificationArrayBytes
|
||||
? null
|
||||
: $"{layout.ElementCount} elements ({layout.ByteLength} B) exceed the {MaxNotificationArrayBytes} B " +
|
||||
"native-notification cap — author a shorter arrayLength or subscribe with UseNativeNotifications=false";
|
||||
|
||||
/// <summary>The byte width the declared type decodes from a notification buffer.</summary>
|
||||
/// <param name="type">The declared type.</param>
|
||||
/// <returns>The width in bytes.</returns>
|
||||
|
||||
@@ -2,21 +2,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by <see cref="ITwinCATClient.AddNotificationAsync"/> when a tag's declared
|
||||
/// <see cref="TwinCATDataType"/> cannot decode the PLC symbol it names (see
|
||||
/// <see cref="TwinCATTypeGuard"/>). The driver isolates the one tag to
|
||||
/// <see cref="TwinCATStatusMapper.BadTypeMismatch"/> and keeps the rest of the subscription.
|
||||
/// <see cref="TwinCATDataType"/> (or, for an array tag, its element type / <c>ArrayLength</c>) cannot
|
||||
/// decode the PLC symbol it names (see <see cref="TwinCATTypeGuard"/>). The driver isolates the one
|
||||
/// tag to <see cref="TwinCATStatusMapper.BadTypeMismatch"/> and keeps the rest of the subscription.
|
||||
/// </summary>
|
||||
internal sealed class TwinCATTypeMismatchException : Exception
|
||||
internal sealed class TwinCATTypeMismatchException : TwinCATNotificationRefusedException
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="TwinCATTypeMismatchException"/> class.</summary>
|
||||
/// <param name="symbolPath">The ADS symbol the tag names.</param>
|
||||
/// <param name="reason">The guard's one-line reason.</param>
|
||||
public TwinCATTypeMismatchException(string symbolPath, string reason)
|
||||
: base($"TwinCAT symbol '{symbolPath}': {reason}")
|
||||
: base(symbolPath, reason, TwinCATStatusMapper.BadTypeMismatch)
|
||||
{
|
||||
SymbolPath = symbolPath;
|
||||
}
|
||||
|
||||
/// <summary>Gets the ADS symbol the refused tag names.</summary>
|
||||
public string SymbolPath { get; }
|
||||
}
|
||||
|
||||
+2
-2
@@ -35,11 +35,11 @@
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="abcipTransport">Transport</label>
|
||||
<InputSelect @bind-Value="_form.Transport" @bind-Value:after="EmitAsync" class="form-select form-select-sm" id="abcipTransport">
|
||||
<option value="">Default (Native)</option>
|
||||
<option value="">Default (node setting; Native unless pinned)</option>
|
||||
<option value="@AbCipTransport.Native">Native (managed)</option>
|
||||
<option value="@AbCipTransport.Libplctag">libplctag (fallback)</option>
|
||||
</InputSelect>
|
||||
<div class="form-text">Which protocol stack talks to the controller. Default omits the setting so the driver picks; takes effect on the next deploy.</div>
|
||||
<div class="form-text">Which protocol stack talks to the controller. Default omits the setting so the node-level <code>Drivers:AbCip:DefaultTransport</code> picks (Native unless an operator pinned it); takes effect on the next deploy.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+4
-4
@@ -22,11 +22,11 @@
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="ablegacyTransport">Transport</label>
|
||||
<InputSelect @bind-Value="_form.Transport" @bind-Value:after="EmitAsync" class="form-select form-select-sm" id="ablegacyTransport">
|
||||
<option value="">Default (Native; libplctag for PLC-5 / LogixPccc)</option>
|
||||
<option value="@AbLegacyTransport.Native">Native (managed)</option>
|
||||
<option value="@AbLegacyTransport.Libplctag">libplctag (fallback)</option>
|
||||
<option value="">Default (libplctag)</option>
|
||||
<option value="@AbLegacyTransport.Native">Native (managed, opt-in)</option>
|
||||
<option value="@AbLegacyTransport.Libplctag">libplctag</option>
|
||||
</InputSelect>
|
||||
<div class="form-text">Which protocol stack talks to the controller. Default omits the setting so the driver picks; takes effect on the next deploy.</div>
|
||||
<div class="form-text">Which protocol stack talks to the controller. Default omits the setting (libplctag). Native is opt-in: wire-proven only for N-file reads/writes and F8 reads. Takes effect on the next deploy.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Node-level defaults for the AbCip driver (final review I1), bound from <c>Drivers:AbCip</c>. Lets an
|
||||
/// operator pin the transport an AbCip driver with NO <c>transport</c> key runs on — at upgrade time,
|
||||
/// without editing (or being able to edit, #576) every driver config. An explicit per-driver
|
||||
/// <c>transport</c> key still wins. Overridable by environment variable
|
||||
/// <c>Drivers__AbCip__DefaultTransport</c>.
|
||||
/// </summary>
|
||||
public sealed class AbCipDriverDefaultsOptions
|
||||
{
|
||||
/// <summary>The configuration section path.</summary>
|
||||
public const string SectionName = "Drivers:AbCip";
|
||||
|
||||
/// <summary>
|
||||
/// <c>Native</c> (the default) or <c>Libplctag</c>, case-insensitive. Blank/absent = <c>Native</c>.
|
||||
/// Any other value fails host start (<see cref="AbCipDriverDefaultsOptionsValidator"/>).
|
||||
/// </summary>
|
||||
public string? DefaultTransport { get; set; } = nameof(AbCipTransport.Native);
|
||||
|
||||
/// <summary>
|
||||
/// The parsed default: blank resolves to the compiled <see cref="AbCipTransportResolver.Default"/>.
|
||||
/// Throws on an unparseable value — unreachable at runtime because the validator fails host start first.
|
||||
/// </summary>
|
||||
/// <returns>The node-level default transport.</returns>
|
||||
public AbCipTransport ResolveDefaultTransport()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(DefaultTransport)) return AbCipTransportResolver.Default;
|
||||
return AbCipTransportResolver.TryParse(DefaultTransport, out var t)
|
||||
? t
|
||||
: throw new InvalidOperationException(AbCipDriverDefaultsOptionsValidator.Message(DefaultTransport));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fail-fast startup validator for <see cref="AbCipDriverDefaultsOptions"/>.</summary>
|
||||
public sealed class AbCipDriverDefaultsOptionsValidator : OptionsValidatorBase<AbCipDriverDefaultsOptions>
|
||||
{
|
||||
/// <summary>The operator-facing failure text for a bad value.</summary>
|
||||
/// <param name="value">The rejected value.</param>
|
||||
/// <returns>The message.</returns>
|
||||
internal static string Message(string? value) =>
|
||||
$"Drivers:AbCip:DefaultTransport is '{value}' — expected one of " +
|
||||
$"{string.Join(", ", Enum.GetNames<AbCipTransport>())} (case-insensitive). It selects the transport an " +
|
||||
"AbCip driver with no \"transport\" key runs on; set it via appsettings or the environment variable " +
|
||||
"Drivers__AbCip__DefaultTransport.";
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Validate(ValidationBuilder builder, AbCipDriverDefaultsOptions options)
|
||||
{
|
||||
builder.RequireThat(
|
||||
string.IsNullOrWhiteSpace(options.DefaultTransport)
|
||||
|| AbCipTransportResolver.TryParse(options.DefaultTransport, out _),
|
||||
Message(options.DefaultTransport));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||
@@ -78,7 +80,11 @@ public static class DriverFactoryBootstrap
|
||||
// whole mechanism exists to prevent, and it would fail as a plant outage rather than as a
|
||||
// startup error.
|
||||
var primaryRoleSource = sp.GetRequiredService<IDriverPrimaryRoleSource>();
|
||||
Register(registry, loggerFactory, secretResolver, primaryRoleSource, scriptRoot);
|
||||
// Final review I2: the native AB transport's process-wide pool logs options conflicts and close
|
||||
// failures; without this it writes to NullLogger in production.
|
||||
ConfigureAbTransportLogging(sp);
|
||||
Register(registry, loggerFactory, secretResolver, primaryRoleSource, ResolveAbCipDefaultTransport(sp),
|
||||
scriptRoot);
|
||||
return registry;
|
||||
});
|
||||
services.AddSingleton<IDriverFactory>(sp =>
|
||||
@@ -140,8 +146,21 @@ public static class DriverFactoryBootstrap
|
||||
public static IServiceCollection AddOtOpcUaDriverProbes(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, ModbusProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, AbCipProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, AbLegacyProbe>());
|
||||
// Factory-built so the probe resolves an absent "transport" key to the SAME node default the driver
|
||||
// factory uses (Drivers:AbCip:DefaultTransport, final review I1) — Test Connect probes the stack the
|
||||
// driver would run. The generic factory overload keeps AbCipProbe as the implementation type, which
|
||||
// TryAddEnumerable de-dups on.
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, AbCipProbe>(
|
||||
sp =>
|
||||
{
|
||||
ConfigureAbTransportLogging(sp); // admin-only nodes reach the pool through the probes alone
|
||||
return new AbCipProbe(tagFactory: null, defaultTransport: ResolveAbCipDefaultTransport(sp));
|
||||
}));
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, AbLegacyProbe>(sp =>
|
||||
{
|
||||
ConfigureAbTransportLogging(sp);
|
||||
return new AbLegacyProbe();
|
||||
}));
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, S7Probe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, TwinCATProbe>());
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, FocasProbe>());
|
||||
@@ -155,6 +174,29 @@ public static class DriverFactoryBootstrap
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gives the native AB transport's process-wide <c>DeviceSessionPool.Shared</c> the host logger factory
|
||||
/// (final review I2). Called from every DI factory that can put the pool to use on this node (the driver
|
||||
/// registry, and the AbCip/AbLegacy probes for admin-only nodes); idempotent. A container without an
|
||||
/// <see cref="ILoggerFactory"/> leaves the pool on its null logger.
|
||||
/// </summary>
|
||||
/// <param name="sp">The service provider.</param>
|
||||
internal static void ConfigureAbTransportLogging(IServiceProvider sp)
|
||||
{
|
||||
if (sp.GetService<ILoggerFactory>() is { } loggerFactory)
|
||||
Driver.Ab.Transport.DeviceSessionPool.ConfigureSharedLogger(loggerFactory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The node-level AbCip default transport (<c>Drivers:AbCip:DefaultTransport</c>, bound + validated in
|
||||
/// Program.cs). A container without the options bound (test graphs) gets the compiled default.
|
||||
/// </summary>
|
||||
/// <param name="sp">The service provider.</param>
|
||||
/// <returns>The transport an AbCip driver with no <c>transport</c> key runs on.</returns>
|
||||
internal static Driver.AbCip.AbCipTransport ResolveAbCipDefaultTransport(IServiceProvider sp) =>
|
||||
sp.GetService<IOptions<AbCipDriverDefaultsOptions>>()?.Value.ResolveDefaultTransport()
|
||||
?? Driver.AbCip.AbCipTransportResolver.Default;
|
||||
|
||||
/// <summary>
|
||||
/// Invoke every cross-platform driver's <c>Register</c> extension. New driver assemblies
|
||||
/// get added here — one line per type. ShouldStub() in <c>DriverInstanceActor</c> still
|
||||
@@ -166,9 +208,10 @@ public static class DriverFactoryBootstrap
|
||||
ILoggerFactory? loggerFactory,
|
||||
ISecretResolver secretResolver,
|
||||
IDriverPrimaryRoleSource primaryRoleSource,
|
||||
Driver.AbCip.AbCipTransport abCipDefaultTransport,
|
||||
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
|
||||
{
|
||||
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry, loggerFactory, abCipDefaultTransport);
|
||||
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
|
||||
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
|
||||
|
||||
@@ -175,6 +175,13 @@ builder.Services.AddOtOpcUaCluster(builder.Configuration);
|
||||
// the startup check (AddOtOpcUaAuth binds LdapOptions but does not attach a validator).
|
||||
builder.Services.AddValidatedOptions<LdapOptions, LdapOptionsValidator>(builder.Configuration, LdapOptions.SectionName);
|
||||
|
||||
// Node-level AbCip default transport (final review I1): the transport an AbCip driver with NO "transport"
|
||||
// key runs on. Unconditional because BOTH consumers need it — the driver factory (driver role) and the
|
||||
// Test Connect probe (admin role, which must probe the stack the driver would run). A bad value fails host
|
||||
// start here rather than silently falling back.
|
||||
builder.Services.AddValidatedOptions<AbCipDriverDefaultsOptions, AbCipDriverDefaultsOptionsValidator>(
|
||||
builder.Configuration, AbCipDriverDefaultsOptions.SectionName);
|
||||
|
||||
if (hasDriver)
|
||||
{
|
||||
builder.Services.AddOtOpcUaRuntime();
|
||||
|
||||
@@ -162,13 +162,12 @@ public sealed class BuildOptionsTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With no <c>--transport</c> the CLI leaves the option null and the driver's resolver decides: Native
|
||||
/// for an SLC 500, libplctag for <c>--plc-type Plc5</c> / LogixPccc (Task 22 exclusion). An explicit
|
||||
/// choice is forwarded verbatim and wins.
|
||||
/// With no <c>--transport</c> the CLI leaves the option null and the driver's resolver decides: libplctag
|
||||
/// for every family (final review I3; Native is opt-in). An explicit choice is forwarded verbatim and wins.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500, AbLegacyTransport.Native)]
|
||||
[InlineData(AbLegacyPlcFamily.MicroLogix, AbLegacyTransport.Native)]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500, AbLegacyTransport.Libplctag)]
|
||||
[InlineData(AbLegacyPlcFamily.MicroLogix, AbLegacyTransport.Libplctag)]
|
||||
[InlineData(AbLegacyPlcFamily.Plc5, AbLegacyTransport.Libplctag)]
|
||||
[InlineData(AbLegacyPlcFamily.LogixPccc, AbLegacyTransport.Libplctag)]
|
||||
public void BuildOptions_default_transport_goes_through_the_resolver(AbLegacyPlcFamily family, AbLegacyTransport expected)
|
||||
@@ -186,13 +185,15 @@ public sealed class BuildOptionsTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The plan's CLI case end to end through CliFx: <c>--plc-type plc5</c> with no <c>--transport</c>
|
||||
/// resolves to libplctag; the same invocation for an SLC resolves Native (falsification control).
|
||||
/// End to end through CliFx: no <c>--transport</c> resolves to libplctag for every <c>--plc-type</c>
|
||||
/// (final review I3). The slc500 / micrologix rows are the falsification rows — they resolved Native
|
||||
/// before the revert.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("plc5", AbLegacyTransport.Libplctag)]
|
||||
[InlineData("logixpccc", AbLegacyTransport.Libplctag)]
|
||||
[InlineData("slc500", AbLegacyTransport.Native)]
|
||||
[InlineData("slc500", AbLegacyTransport.Libplctag)]
|
||||
[InlineData("micrologix", AbLegacyTransport.Libplctag)]
|
||||
public async Task Plc_type_without_transport_flag_resolves_through_the_resolver(string plcType, AbLegacyTransport expected)
|
||||
{
|
||||
CapturingCommand.Options = null;
|
||||
|
||||
@@ -191,4 +191,73 @@ public sealed class AbCipTransportSwitchTests
|
||||
r.Ok.ShouldBeFalse();
|
||||
r.Message!.ShouldContain("Config JSON is invalid");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Final review I1 precedence: explicit key > node default > compiled Native. The pinned rows are
|
||||
/// the falsification rows — a resolver that ignores the node default returns Native for them.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(null, AbCipTransport.Libplctag, AbCipTransport.Libplctag)]
|
||||
[InlineData(null, AbCipTransport.Native, AbCipTransport.Native)]
|
||||
[InlineData(AbCipTransport.Native, AbCipTransport.Libplctag, AbCipTransport.Native)]
|
||||
[InlineData(AbCipTransport.Libplctag, AbCipTransport.Native, AbCipTransport.Libplctag)]
|
||||
public void Explicit_key_beats_node_default_beats_compiled_default(
|
||||
AbCipTransport? configured, AbCipTransport nodeDefault, AbCipTransport expected)
|
||||
{
|
||||
AbCipTransportResolver.Resolve(configured, nodeDefault).ShouldBe(expected);
|
||||
var json = configured is null ? Config(null) : Config($"\"{configured}\"");
|
||||
AbCipDriverFactoryExtensions.CreateInstance("ab1", json, loggerFactory: null, defaultTransport: nodeDefault)
|
||||
.EffectiveTransport.ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>The init-log rendering distinguishes the compiled default from a node pin.</summary>
|
||||
[Fact]
|
||||
public void Describe_distinguishes_compiled_default_node_default_and_configured()
|
||||
{
|
||||
AbCipTransportResolver.Describe(null).ShouldBe("Native (default)");
|
||||
AbCipTransportResolver.Describe(null, AbCipTransport.Libplctag).ShouldBe("Libplctag (node default)");
|
||||
AbCipTransportResolver.Describe(AbCipTransport.Native, AbCipTransport.Libplctag).ShouldBe("Native (configured)");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Native", true)]
|
||||
[InlineData("libplctag", true)]
|
||||
[InlineData(" Libplctag ", true)]
|
||||
[InlineData("1", false)]
|
||||
[InlineData("Native,Libplctag", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData("Bogus", false)]
|
||||
public void TryParse_accepts_names_only(string raw, bool ok) =>
|
||||
AbCipTransportResolver.TryParse(raw, out _).ShouldBe(ok);
|
||||
|
||||
/// <summary>
|
||||
/// The probe honours its node default for an absent key: pinned to Libplctag it does NOT produce the
|
||||
/// native failure text against the same listener the unpinned probe does (falsification control).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Probe_with_no_transport_uses_the_node_default()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
try { (await listener.AcceptTcpClientAsync(TestContext.Current.CancellationToken)).Dispose(); }
|
||||
catch { return; }
|
||||
}
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
var cfg = $$"""{"devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0"}]}""";
|
||||
var pinned = await new AbCipDriverProbe(defaultTransport: AbCipTransport.Libplctag)
|
||||
.ProbeAsync(cfg, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
||||
var unpinned = await new AbCipDriverProbe()
|
||||
.ProbeAsync(cfg, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
||||
listener.Stop();
|
||||
|
||||
unpinned.Message!.ShouldContain("CIP handshake failed: Connection lost");
|
||||
pinned.Ok.ShouldBeFalse();
|
||||
pinned.Message!.ShouldNotContain("Connection lost");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
||||
using CapturingLogger = ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.AbCipLoggingTests.CapturingLogger<ZB.MOM.WW.OtOpcUa.Driver.AbCip.AbCipDriver>;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Final review I4: a type mismatch (the controller answered with a type that disagrees with the authored
|
||||
/// one) is a deterministic configuration refusal, handled like <see cref="AbStatus.Unsupported"/>:
|
||||
/// <c>BadTypeMismatch</c>, the handle is KEPT (one create across N polls), health is unchanged, and the
|
||||
/// Warning is logged once per tag — not per poll. Before the fix each poll evicted the runtime, so a sole
|
||||
/// lease holder cost a Forward Close + Forward Open on every poll and the driver sat Degraded. The
|
||||
/// control tests (a genuine transport failure still evicts and degrades) live in
|
||||
/// <see cref="AbCipLocalRefusalTests"/> and below.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipTypeMismatchTests
|
||||
{
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
private const int Polls = 4;
|
||||
|
||||
/// <summary>A fake whose read completes but reports <see cref="AbStatus.TypeMismatch"/> (the native
|
||||
/// runtime's same-width check), and whose write throws it (the native write gate / CIP 0xFF 0x2107).</summary>
|
||||
private sealed class MismatchTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
|
||||
{
|
||||
public override async Task ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await base.ReadAsync(cancellationToken);
|
||||
Status = (int)AbStatus.TypeMismatch;
|
||||
}
|
||||
|
||||
public override Task WriteAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromException(new AbTransportException(AbStatus.TypeMismatch, "REAL on the controller, DINT authored"));
|
||||
}
|
||||
|
||||
private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory, Func<int> Creates, CapturingLogger Log)>
|
||||
NewAsync()
|
||||
{
|
||||
var creates = 0;
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => { Interlocked.Increment(ref creates); return new MismatchTag(p) { Value = 1 }; },
|
||||
};
|
||||
var log = new CapturingLogger();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Host)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", Host, "T", AbCipDataType.DInt, Writable: true)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-i4", factory, logger: log);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
return (drv, factory, () => Volatile.Read(ref creates), log);
|
||||
}
|
||||
|
||||
private static int MismatchWarnings(CapturingLogger log) =>
|
||||
log.Entries.Count(e => e.Level == LogLevel.Warning && e.Message.Contains("logged once per tag"));
|
||||
|
||||
#pragma warning disable OTOPCUA0001 // the driver's own status handling is under test, not resilience wrapping
|
||||
[Fact]
|
||||
public async Task A_type_mismatched_read_keeps_the_handle_leaves_health_alone_and_warns_once()
|
||||
{
|
||||
var (drv, factory, creates, log) = await NewAsync();
|
||||
var health = drv.GetHealth();
|
||||
|
||||
for (var n = 0; n < Polls; n++)
|
||||
(await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
|
||||
|
||||
creates().ShouldBe(1);
|
||||
factory.Tags["T"].Disposed.ShouldBeFalse();
|
||||
factory.Tags["T"].ReadCount.ShouldBe(Polls); // positive control: every poll did reach the runtime
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
MismatchWarnings(log).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_type_mismatched_write_keeps_the_handle_leaves_health_alone_and_warns_once()
|
||||
{
|
||||
var (drv, factory, creates, log) = await NewAsync();
|
||||
var health = drv.GetHealth();
|
||||
|
||||
for (var n = 0; n < Polls; n++)
|
||||
(await drv.WriteAsync([new WriteRequest("T", 5)], CancellationToken.None)).Single().StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
|
||||
|
||||
creates().ShouldBe(1);
|
||||
factory.Tags["T"].Disposed.ShouldBeFalse();
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
MismatchWarnings(log).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Control: a genuine transport failure on a READ still evicts and degrades.</summary>
|
||||
[Fact]
|
||||
public async Task A_transport_failure_read_still_evicts_and_degrades()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection } };
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Host)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", Host, "T", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-i4", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
(await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode
|
||||
.ShouldNotBe(AbCipStatusMapper.BadTypeMismatch);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
factory.Tags["T"].Disposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End to end over the REAL native runtime + a private <see cref="DeviceSessionPool"/> against a fake
|
||||
/// CIP target that answers every read with a REAL for a DINT-authored tag. The tag is the only lease
|
||||
/// holder (probe off), so an evict would be the LAST release: Forward Close, then a throttled Forward
|
||||
/// Open on the next poll. One Forward Open across N polls and no Forward Close proves there is no
|
||||
/// connection churn.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_native_type_mismatch_causes_no_connection_churn()
|
||||
{
|
||||
await using var target = new FakeCipTarget();
|
||||
// Read Tag Fragmented (0x52) → success reply typed REAL (0x00CA) with 4 data bytes.
|
||||
target.ConnectedHandler = req => Task.FromResult<byte[]?>(
|
||||
req[0] == 0x52 ? [0xD2, 0x00, 0x00, 0x00, 0xCA, 0x00, 0x00, 0x00, 0x80, 0x3F] : target.ServiceReply(req));
|
||||
var pool = new DeviceSessionPool();
|
||||
var host = $"ab://127.0.0.1:{target.Port}/1,0";
|
||||
var log = new CapturingLogger();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)],
|
||||
RawTags = AbCipRawTags.From(new AbCipTagDefinition("T", host, "T", AbCipDataType.DInt)),
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
}, "drv-i4-native", new NativeAbCipTagFactory(pool), logger: log);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var health = drv.GetHealth();
|
||||
try
|
||||
{
|
||||
for (var n = 0; n < Polls; n++)
|
||||
(await drv.ReadAsync(["T"], CancellationToken.None)).Single().StatusCode
|
||||
.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
|
||||
|
||||
target.ForwardOpens.ShouldBe(1);
|
||||
target.ForwardCloses.ShouldBe(0);
|
||||
target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(Polls); // positive control: polls hit the wire
|
||||
drv.GetHealth().ShouldBe(health);
|
||||
MismatchWarnings(log).ShouldBe(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
#pragma warning restore OTOPCUA0001
|
||||
}
|
||||
@@ -36,6 +36,28 @@ public sealed class LibplctagOrphanReleaseTests
|
||||
LibplctagOrphanRelease.CanReadHandles.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Final review M2: the handle field's TYPE is pinned explicitly, not just its presence. A retype (IntPtr,
|
||||
/// long) now degrades to the rooting fallback instead of an InvalidCastException inside a catch block,
|
||||
/// and this names the reason when it happens.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_pinned_wrapper_handle_field_is_still_an_int()
|
||||
{
|
||||
const System.Reflection.BindingFlags priv =
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance;
|
||||
var wrapper = typeof(libplctag.Tag).GetField("_tag", priv).ShouldNotBeNull();
|
||||
wrapper.FieldType.GetField("nativeTagHandle", priv).ShouldNotBeNull().FieldType.ShouldBe(typeof(int));
|
||||
}
|
||||
|
||||
/// <summary>HandleOf never throws (it runs inside the runtimes' catch blocks); a never-created tag reads 0.</summary>
|
||||
[Fact]
|
||||
public void HandleOf_a_fresh_tag_is_zero_and_does_not_throw()
|
||||
{
|
||||
using var tag = new libplctag.Tag();
|
||||
LibplctagOrphanRelease.HandleOf(tag).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_initialisation_destroys_the_native_tag_it_created()
|
||||
{
|
||||
|
||||
+2
@@ -34,6 +34,8 @@
|
||||
<ItemGroup>
|
||||
<!-- Native transport test fake, source-linked so it stays internal (Task 12). -->
|
||||
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\Fakes\FakeCipChannel.cs" Link="Fakes\FakeCipChannel.cs" />
|
||||
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\Fakes\FakeEipServer.cs" Link="Fakes\FakeEipServer.cs" />
|
||||
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\Fakes\FakeCipTarget.cs" Link="Fakes\FakeCipTarget.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+4
-3
@@ -15,12 +15,12 @@ public sealed class AbLegacyTransportInitLogTests
|
||||
private static string Config(string? transport, string family = "Slc500") =>
|
||||
$$"""{{{(transport is null ? "" : $"\"transport\":\"{transport}\",")}}"probe":{"enabled":false},"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"{{family}}"}]}""";
|
||||
|
||||
/// <summary>The init line names the configured-vs-effective transport (Task 22).</summary>
|
||||
/// <summary>The init line names the configured-vs-effective transport (final review I3: default = Libplctag).</summary>
|
||||
[Theory]
|
||||
[InlineData("Native", "Slc500", "Native (configured)")]
|
||||
[InlineData("Libplctag", "Slc500", "Libplctag (configured)")]
|
||||
[InlineData(null, "Slc500", "Native (default)")]
|
||||
[InlineData(null, "Plc5", "Libplctag (default; PLC-5/LogixPccc device present)")]
|
||||
[InlineData(null, "Slc500", "Libplctag (default)")]
|
||||
[InlineData(null, "Plc5", "Libplctag (default)")]
|
||||
public async Task Init_logs_the_transport_through_the_registered_logger_factory(
|
||||
string? json, string family, string expected)
|
||||
{
|
||||
@@ -37,6 +37,7 @@ public sealed class AbLegacyTransportInitLogTests
|
||||
[InlineData("Native", "Plc5", true)]
|
||||
[InlineData("Native", "LogixPccc", true)]
|
||||
[InlineData(null, "Plc5", false)]
|
||||
[InlineData(null, "Slc500", false)]
|
||||
[InlineData("Libplctag", "LogixPccc", false)]
|
||||
[InlineData("Native", "Slc500", false)]
|
||||
public async Task Explicit_Native_on_a_limited_family_warns_once(string? json, string family, bool warns)
|
||||
|
||||
+35
-24
@@ -24,13 +24,13 @@ public sealed class AbLegacyTransportSwitchTests
|
||||
|
||||
/// <summary>
|
||||
/// Transport values select the factory type; absent parses to null ("use the default") and the driver
|
||||
/// resolves that to Native for an SLC 500 (Task 22). An explicit Libplctag still wins.
|
||||
/// resolves that to Libplctag (final review I3 — Native is opt-in). An explicit Native still wins.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("\"Native\"", AbLegacyTransport.Native)]
|
||||
[InlineData("\"native\"", AbLegacyTransport.Native)]
|
||||
[InlineData("\"Libplctag\"", AbLegacyTransport.Libplctag)]
|
||||
[InlineData(null, AbLegacyTransport.Native)]
|
||||
[InlineData(null, AbLegacyTransport.Libplctag)]
|
||||
public void Config_transport_selects_the_factory(string? json, AbLegacyTransport expected)
|
||||
{
|
||||
var parsed = AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config(json));
|
||||
@@ -46,35 +46,42 @@ public sealed class AbLegacyTransportSwitchTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The exclusion: an absent key resolves the WHOLE driver to libplctag when any device is a PLC-5 or a
|
||||
/// LogixPccc (limited native support); the factory is driver-level, so one such device among SLCs
|
||||
/// is enough. The SLC/MicroLogix-only rows are the falsification control — they must resolve Native.
|
||||
/// Final review I3: an absent key resolves EVERY family to libplctag — the SLC/MicroLogix rows are the
|
||||
/// ones that change (they defaulted Native before), so they are the falsification rows: reverting the
|
||||
/// resolver to "Native unless PLC-5/LogixPccc" turns them red.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(AbLegacyTransport.Libplctag, "Plc5")]
|
||||
[InlineData(AbLegacyTransport.Libplctag, "LogixPccc")]
|
||||
[InlineData(AbLegacyTransport.Libplctag, "Slc500", "Plc5")]
|
||||
[InlineData(AbLegacyTransport.Libplctag, "MicroLogix", "LogixPccc")]
|
||||
[InlineData(AbLegacyTransport.Native, "Slc500")]
|
||||
[InlineData(AbLegacyTransport.Native, "MicroLogix")]
|
||||
[InlineData(AbLegacyTransport.Native, "Slc500", "MicroLogix")]
|
||||
public void Absent_transport_excludes_PLC5_and_LogixPccc(AbLegacyTransport expected, params string[] families)
|
||||
[InlineData("Plc5")]
|
||||
[InlineData("LogixPccc")]
|
||||
[InlineData("Slc500", "Plc5")]
|
||||
[InlineData("Slc500")]
|
||||
[InlineData("MicroLogix")]
|
||||
[InlineData("Slc500", "MicroLogix")]
|
||||
public void Absent_transport_resolves_libplctag_for_every_family(params string[] families)
|
||||
{
|
||||
var json = Config(null, families);
|
||||
AbLegacyTransportResolver.Resolve(AbLegacyDriverFactoryExtensions.ParseOptions("ab1", json)).ShouldBe(expected);
|
||||
AbLegacyTransportResolver.Resolve(AbLegacyDriverFactoryExtensions.ParseOptions("ab1", json))
|
||||
.ShouldBe(AbLegacyTransport.Libplctag);
|
||||
|
||||
var driver = AbLegacyDriverFactoryExtensions.CreateInstance("ab1", json);
|
||||
driver.EffectiveTransport.ShouldBe(expected);
|
||||
if (expected == AbLegacyTransport.Native)
|
||||
driver.TagFactory.ShouldBeOfType<NativeAbLegacyTagFactory>();
|
||||
else
|
||||
driver.TagFactory.ShouldBeOfType<LibplctagLegacyTagFactory>();
|
||||
driver.EffectiveTransport.ShouldBe(AbLegacyTransport.Libplctag);
|
||||
driver.TagFactory.ShouldBeOfType<LibplctagLegacyTagFactory>();
|
||||
}
|
||||
|
||||
/// <summary>An explicit value always wins over the exclusion, in both directions.</summary>
|
||||
/// <summary>The init-log rendering names the default as such, and an explicit choice as configured.</summary>
|
||||
[Fact]
|
||||
public void Describe_names_default_and_configured()
|
||||
{
|
||||
AbLegacyTransportResolver.Describe(null).ShouldBe("Libplctag (default)");
|
||||
AbLegacyTransportResolver.Describe(AbLegacyTransport.Native).ShouldBe("Native (configured)");
|
||||
AbLegacyTransportResolver.Describe(AbLegacyTransport.Libplctag).ShouldBe("Libplctag (configured)");
|
||||
}
|
||||
|
||||
/// <summary>An explicit value always wins, for every family.</summary>
|
||||
[Theory]
|
||||
[InlineData("\"Native\"", "Plc5", AbLegacyTransport.Native)]
|
||||
[InlineData("\"Native\"", "LogixPccc", AbLegacyTransport.Native)]
|
||||
[InlineData("\"Native\"", "Slc500", AbLegacyTransport.Native)]
|
||||
[InlineData("\"Libplctag\"", "Slc500", AbLegacyTransport.Libplctag)]
|
||||
public void Explicit_transport_wins_over_the_exclusion(string json, string family, AbLegacyTransport expected)
|
||||
{
|
||||
@@ -171,8 +178,8 @@ public sealed class AbLegacyTransportSwitchTests
|
||||
|
||||
/// <summary>
|
||||
/// The probe resolves an ABSENT transport through the same resolver as the driver: an SLC 500 with no
|
||||
/// key takes the native path (native failure text), a PLC-5 with no key takes libplctag (no native
|
||||
/// text) — against the same listener, so only the resolution differs.
|
||||
/// key takes libplctag (no native failure text) exactly as a PLC-5 does, while an explicit Native on the
|
||||
/// same listener produces the native text — the control that proves the text discriminates.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Probe_with_no_transport_resolves_like_the_driver()
|
||||
@@ -191,19 +198,23 @@ public sealed class AbLegacyTransportSwitchTests
|
||||
|
||||
string Cfg(string family) =>
|
||||
$$"""{"devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0","plcFamily":"{{family}}"}]}""";
|
||||
var nativeCfg =
|
||||
$$"""{"transport":"Native","devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0","plcFamily":"Slc500"}]}""";
|
||||
var probe = new AbLegacyDriverProbe();
|
||||
|
||||
var slc = await probe.ProbeAsync(Cfg("Slc500"), TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
||||
var plc5 = await probe.ProbeAsync(Cfg("Plc5"), TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
||||
var native = await probe.ProbeAsync(nativeCfg, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
||||
listener.Stop();
|
||||
|
||||
AbLegacyTransportResolver.Resolve(AbLegacyDriverFactoryExtensions.ParseOptions("p", Cfg("Slc500")))
|
||||
.ShouldBe(AbLegacyTransport.Native);
|
||||
.ShouldBe(AbLegacyTransport.Libplctag);
|
||||
AbLegacyTransportResolver.Resolve(AbLegacyDriverFactoryExtensions.ParseOptions("p", Cfg("Plc5")))
|
||||
.ShouldBe(AbLegacyTransport.Libplctag);
|
||||
slc.Ok.ShouldBeFalse();
|
||||
slc.Message!.ShouldContain("PCCC handshake failed: Connection lost");
|
||||
slc.Message!.ShouldNotContain("Connection lost");
|
||||
plc5.Message!.ShouldNotContain("Connection lost");
|
||||
native.Message!.ShouldContain("PCCC handshake failed: Connection lost");
|
||||
}
|
||||
|
||||
/// <summary>The probe deserialises with its own string-enum converter; a bad transport is a config error.</summary>
|
||||
|
||||
+22
@@ -16,6 +16,28 @@ public sealed class LibplctagLegacyOrphanReleaseTests
|
||||
public void The_pinned_libplctag_wrapper_still_exposes_the_native_handle() =>
|
||||
LibplctagOrphanRelease.CanReadHandles.ShouldBeTrue();
|
||||
|
||||
/// <summary>
|
||||
/// Final review M2: the handle field's TYPE is pinned explicitly, not just its presence. A retype (IntPtr,
|
||||
/// long) now degrades to the rooting fallback instead of an InvalidCastException inside a catch block,
|
||||
/// and this names the reason when it happens.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_pinned_wrapper_handle_field_is_still_an_int()
|
||||
{
|
||||
const System.Reflection.BindingFlags priv =
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance;
|
||||
var wrapper = typeof(libplctag.Tag).GetField("_tag", priv).ShouldNotBeNull();
|
||||
wrapper.FieldType.GetField("nativeTagHandle", priv).ShouldNotBeNull().FieldType.ShouldBe(typeof(int));
|
||||
}
|
||||
|
||||
/// <summary>HandleOf never throws (it runs inside the runtimes' catch blocks); a never-created tag reads 0.</summary>
|
||||
[Fact]
|
||||
public void HandleOf_a_fresh_tag_is_zero_and_does_not_throw()
|
||||
{
|
||||
using var tag = new libplctag.Tag();
|
||||
LibplctagOrphanRelease.HandleOf(tag).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_initialisation_destroys_the_native_tag_it_created()
|
||||
{
|
||||
|
||||
@@ -129,13 +129,14 @@ internal class FakeTwinCATClient : ITwinCATClient
|
||||
/// <param name="symbolPath">The path to the symbol to watch.</param>
|
||||
/// <param name="type">The data type of the symbol.</param>
|
||||
/// <param name="bitIndex">The optional bit index for bit-level notifications.</param>
|
||||
/// <param name="arrayCount">The authored array length for an array tag; null for a scalar.</param>
|
||||
/// <param name="cycleTime">The sampling cycle time.</param>
|
||||
/// <param name="maxDelayMs">The maximum delay in milliseconds.</param>
|
||||
/// <param name="onChange">The callback to invoke on value change.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that returns a notification handle.</returns>
|
||||
public virtual async Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
||||
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime,
|
||||
string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, TimeSpan cycleTime,
|
||||
int maxDelayMs, Action<string, object?> onChange, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ThrowOnAddNotification)
|
||||
@@ -147,7 +148,7 @@ internal class FakeTwinCATClient : ITwinCATClient
|
||||
await gate.Task.ConfigureAwait(false);
|
||||
|
||||
LastMaxDelayMs = maxDelayMs;
|
||||
var reg = new FakeNotification(symbolPath, type, bitIndex, onChange, this);
|
||||
var reg = new FakeNotification(symbolPath, type, bitIndex, onChange, this) { ArrayCount = arrayCount };
|
||||
Notifications.Add(reg);
|
||||
return reg;
|
||||
}
|
||||
@@ -195,6 +196,8 @@ internal class FakeTwinCATClient : ITwinCATClient
|
||||
public TwinCATDataType Type { get; } = type;
|
||||
/// <summary>Gets the optional bit index.</summary>
|
||||
public int? BitIndex { get; } = bitIndex;
|
||||
/// <summary>Gets the array count the driver registered with (null = scalar).</summary>
|
||||
public int? ArrayCount { get; init; }
|
||||
/// <summary>Gets the callback to invoke on value change.</summary>
|
||||
public Action<string, object?> OnChange { get; } = onChange;
|
||||
/// <summary>Gets a value indicating whether this notification has been disposed.</summary>
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using TwinCAT.TypeSystem;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the array half of the raw-bytes model: <see cref="TwinCATTypeGuard.CheckArray"/> decides
|
||||
/// whether an authored array tag (element type + <c>ArrayLength</c>) may decode a PLC array and
|
||||
/// plans the byte layout, and <see cref="TwinCATArrayCodec.Decode"/> turns the PLC's raw bytes into
|
||||
/// the element-typed CLR array both the poll read and the native notification publish.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The raw-bytes model replaced the Beckhoff typed array read after the 2026-10-07 live gate on the
|
||||
/// TwinCAT 2.11 Elma cleaner Z33856, where that read failed for most real arrays: the CLR element
|
||||
/// type had to match exactly (<c>ARRAY OF TIME</c> as <c>uint[]</c> threw), a multi-dimensional
|
||||
/// array demanded a rank-2 CLR array, and a dotted MEMBER array was sliced by the under-reported
|
||||
/// symbol size (<c>P_INPUTVALUESFILTER.FBUSPWR1G1.SAMPLE</c>, <c>ARRAY [1..20] OF REAL</c>, symbol
|
||||
/// 10 B vs type 80 B → <c>ArgumentOutOfRangeException</c>).
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATArrayCodecTests
|
||||
{
|
||||
private static TwinCATPlcSymbolShape Elem(string typeName, int byteSize, DataTypeCategory? category = DataTypeCategory.Primitive)
|
||||
=> TwinCATPlcSymbolShape.Classify(typeName, category, byteSize);
|
||||
|
||||
private static TwinCATPlcArrayShape Arr(TwinCATPlcSymbolShape element, params int[] dims)
|
||||
=> new(dims, dims.Aggregate(1, (a, b) => a * b) * element.ByteSize, element);
|
||||
|
||||
private static TwinCATPlcSymbolShape ArraySymbol(string typeName, int byteSize)
|
||||
=> TwinCATPlcSymbolShape.Classify(typeName, DataTypeCategory.Array, byteSize);
|
||||
|
||||
private static string? Check(TwinCATDataType declared, int declaredLength, TwinCATPlcArrayShape? array,
|
||||
out TwinCATArrayLayout layout, string typeName = "ARRAY")
|
||||
=> TwinCATTypeGuard.CheckArray(declared, declaredLength,
|
||||
ArraySymbol(typeName, array?.ByteSize ?? 4), array, out layout);
|
||||
|
||||
// ---- guard: plan + validate ----
|
||||
|
||||
/// <summary>A matching element type and length plans the whole array at element-size stride.</summary>
|
||||
[Fact]
|
||||
public void CheckArray_plans_a_matching_numeric_array_at_its_element_stride()
|
||||
{
|
||||
Check(TwinCATDataType.UInt, 13, Arr(Elem("UINT", 2), 13), out var layout).ShouldBeNull();
|
||||
layout.ShouldBe(new TwinCATArrayLayout(13, 2));
|
||||
layout.ByteLength.ShouldBe(26);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A multi-dimensional array (<c>ARRAY [1..3, 1..2] OF REAL</c>, live on Z33856) is published
|
||||
/// flattened in PLC memory order; its ArrayLength is the TOTAL element count. Lower bounds play
|
||||
/// no part — the published index is 0-based.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CheckArray_flattens_a_multi_dimensional_array_to_its_total_count()
|
||||
{
|
||||
Check(TwinCATDataType.Real, 6, Arr(Elem("REAL", 4), 3, 2), out var layout).ShouldBeNull();
|
||||
layout.ShouldBe(new TwinCATArrayLayout(6, 4));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An authored ArrayLength SHORTER than the PLC array reads the leading sub-range only: the first
|
||||
/// N elements in PLC memory order, at N × stride bytes from the array start — cheap (a smaller
|
||||
/// notification) and well-defined, and it matches the node's advertised ArrayDimensions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CheckArray_plans_a_leading_sub_range_when_the_authored_length_is_shorter()
|
||||
{
|
||||
Check(TwinCATDataType.Real, 5, Arr(Elem("REAL", 4), 20), out var layout).ShouldBeNull();
|
||||
layout.ShouldBe(new TwinCATArrayLayout(5, 4));
|
||||
layout.ByteLength.ShouldBe(20);
|
||||
}
|
||||
|
||||
/// <summary>An authored ArrayLength LONGER than the PLC array would read past it — refused.</summary>
|
||||
[Fact]
|
||||
public void CheckArray_refuses_an_authored_length_longer_than_the_plc_array()
|
||||
=> Check(TwinCATDataType.UInt, 14, Arr(Elem("UINT", 2), 13), out _)!.ShouldContain("13");
|
||||
|
||||
/// <summary>A zero or negative authored length is not an array.</summary>
|
||||
[Fact]
|
||||
public void CheckArray_refuses_a_non_positive_authored_length()
|
||||
=> Check(TwinCATDataType.UInt, 0, Arr(Elem("UINT", 2), 13), out _).ShouldNotBeNull();
|
||||
|
||||
/// <summary>
|
||||
/// The element is checked with the same rules as a scalar notification (TwinCAT-002/-008): width,
|
||||
/// float/integer kind and string kind must agree; a same-width, same-kind divergence is tolerated.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CheckArray_checks_the_element_like_a_scalar_notification()
|
||||
{
|
||||
Check(TwinCATDataType.DInt, 4, Arr(Elem("REAL", 4), 4), out _).ShouldNotBeNull(); // float kind
|
||||
Check(TwinCATDataType.Int, 4, Arr(Elem("DINT", 4), 4), out _).ShouldNotBeNull(); // width
|
||||
Check(TwinCATDataType.DInt, 4, Arr(Elem("STRING(80)", 81, DataTypeCategory.String), 4), out _).ShouldNotBeNull();
|
||||
Check(TwinCATDataType.String, 4, Arr(Elem("INT", 2), 4), out _).ShouldNotBeNull();
|
||||
Check(TwinCATDataType.WString, 4, Arr(Elem("STRING(80)", 81, DataTypeCategory.String), 4), out _).ShouldNotBeNull();
|
||||
|
||||
Check(TwinCATDataType.Int, 4, Arr(Elem("UINT", 2), 4), out _).ShouldBeNull(); // same width, same kind
|
||||
Check(TwinCATDataType.Time, 13, Arr(Elem("TIME", 4), 13), out _).ShouldBeNull();
|
||||
Check(TwinCATDataType.USInt, 6, Arr(Elem("USINT", 1), 6), out _).ShouldBeNull();
|
||||
Check(TwinCATDataType.SInt, 13, Arr(Elem("BYTE", 1), 13), out _).ShouldBeNull();
|
||||
Check(TwinCATDataType.Bool, 19, Arr(Elem("BOOL", 1), 19), out _).ShouldBeNull();
|
||||
Check(TwinCATDataType.LReal, 32, Arr(Elem("LREAL", 8), 32), out _).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>A STRING(n) / WSTRING(n) element is laid out at the PLC's own width: n+1 / 2(n+1).</summary>
|
||||
[Fact]
|
||||
public void CheckArray_strides_a_string_array_by_the_plc_element_width()
|
||||
{
|
||||
Check(TwinCATDataType.String, 10, Arr(Elem("STRING(80)", 81, DataTypeCategory.String), 10), out var s).ShouldBeNull();
|
||||
s.ShouldBe(new TwinCATArrayLayout(10, 81));
|
||||
Check(TwinCATDataType.WString, 3, Arr(Elem("WSTRING(20)", 42, DataTypeCategory.String), 3), out var w).ShouldBeNull();
|
||||
w.ShouldBe(new TwinCATArrayLayout(3, 42));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Arrays of structs / UDTs / FBs, of pointers, and arrays of arrays (jagged) have no scalar element
|
||||
/// the driver can decode — refused with a reason that says so.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CheckArray_refuses_struct_pointer_and_nested_array_elements()
|
||||
{
|
||||
Check(TwinCATDataType.DInt, 13, Arr(Elem("ST_CARRIERDATA", 120, DataTypeCategory.Struct), 13), out _).ShouldNotBeNull();
|
||||
Check(TwinCATDataType.DInt, 4, Arr(Elem("POINTER TO BYTE", 4, DataTypeCategory.Pointer), 4), out _).ShouldNotBeNull();
|
||||
Check(TwinCATDataType.Int, 2, Arr(Elem("ARRAY [0..2] OF INT", 6, DataTypeCategory.Array), 2), out _).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An array tag over a PLC symbol that is not an array (a scalar, a string, an element path such
|
||||
/// as <c>Arr[3]</c>) is refused: there is no array to read.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CheckArray_refuses_a_plc_symbol_that_is_not_an_array()
|
||||
{
|
||||
TwinCATTypeGuard.CheckArray(TwinCATDataType.DInt, 4, Elem("DINT", 4), null, out _).ShouldNotBeNull();
|
||||
TwinCATTypeGuard.CheckArray(TwinCATDataType.String, 4,
|
||||
Elem("STRING(80)", 81, DataTypeCategory.String), null, out _).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>A <c>VAR_IN_OUT</c> array member is a reference, not the array — refused.</summary>
|
||||
[Fact]
|
||||
public void CheckArray_refuses_a_var_in_out_reference()
|
||||
{
|
||||
var symbol = TwinCATPlcSymbolShape.Classify("ARRAY [1..3] OF INT", DataTypeCategory.Array, 4,
|
||||
dataTypeByteSize: 6, symbolTypeName: "ARRAY [1..3] OF INT (VAR_IN_OUT)");
|
||||
TwinCATTypeGuard.CheckArray(TwinCATDataType.Int, 3, symbol, Arr(Elem("INT", 2), 3), out _).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A layout whose element count × element size disagrees with the array's own byte size is not
|
||||
/// a packed array the driver understands (padding, a mis-resolved element) — refused rather than
|
||||
/// decoded at the wrong offsets.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CheckArray_refuses_an_array_whose_size_is_not_count_times_element_size()
|
||||
{
|
||||
var padded = new TwinCATPlcArrayShape([4], 32, Elem("INT", 2));
|
||||
Check(TwinCATDataType.Int, 4, padded, out _).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// ---- guard: notification size ----
|
||||
|
||||
/// <summary>
|
||||
/// A native notification is capped at <see cref="TwinCATTypeGuard.MaxNotificationArrayBytes"/>
|
||||
/// (128 KiB — the largest registration proven on a real target: a TC 2.11
|
||||
/// <c>ARRAY [0..65535] OF UINT</c>). Above it the tag is refused with a reason pointing at polling.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CheckNotificationSize_caps_a_native_array_at_128_KiB()
|
||||
{
|
||||
TwinCATTypeGuard.MaxNotificationArrayBytes.ShouldBe(128 * 1024);
|
||||
TwinCATTypeGuard.CheckNotificationSize(new TwinCATArrayLayout(65536, 2)).ShouldBeNull();
|
||||
TwinCATTypeGuard.CheckNotificationSize(new TwinCATArrayLayout(65537, 2))!.ShouldContain("UseNativeNotifications");
|
||||
}
|
||||
|
||||
// ---- codec: decode ----
|
||||
|
||||
/// <summary>Each numeric element type decodes little-endian into its CLR array, in PLC memory order.</summary>
|
||||
[Fact]
|
||||
public void Decode_numeric_elements_little_endian_into_the_declared_clr_array()
|
||||
{
|
||||
Decode<short>(TwinCATDataType.Int, 2, [-1, 300], (b, v) => BinaryPrimitives.WriteInt16LittleEndian(b, v));
|
||||
Decode<ushort>(TwinCATDataType.UInt, 2, [65535, 7], (b, v) => BinaryPrimitives.WriteUInt16LittleEndian(b, v));
|
||||
Decode<int>(TwinCATDataType.DInt, 4, [-123456, 42], (b, v) => BinaryPrimitives.WriteInt32LittleEndian(b, v));
|
||||
Decode<uint>(TwinCATDataType.UDInt, 4, [4_000_000_000, 1], (b, v) => BinaryPrimitives.WriteUInt32LittleEndian(b, v));
|
||||
Decode<long>(TwinCATDataType.LInt, 8, [long.MinValue, 9], (b, v) => BinaryPrimitives.WriteInt64LittleEndian(b, v));
|
||||
Decode<ulong>(TwinCATDataType.ULInt, 8, [ulong.MaxValue, 3], (b, v) => BinaryPrimitives.WriteUInt64LittleEndian(b, v));
|
||||
Decode<float>(TwinCATDataType.Real, 4, [949.2f, -0.5f], (b, v) => BinaryPrimitives.WriteSingleLittleEndian(b, v));
|
||||
Decode<double>(TwinCATDataType.LReal, 8, [Math.PI, -1e300], (b, v) => BinaryPrimitives.WriteDoubleLittleEndian(b, v));
|
||||
Decode<sbyte>(TwinCATDataType.SInt, 1, [-128, 127], (b, v) => b[0] = unchecked((byte)v));
|
||||
Decode<byte>(TwinCATDataType.USInt, 1, [0, 255], (b, v) => b[0] = v);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TIME / DATE / DT / TOD elements publish as their raw 32-bit count (<c>uint[]</c>) — the same
|
||||
/// CLR shape a scalar of those types publishes. Live: <c>.THROTTLE_MODE_DEFAULT</c>
|
||||
/// (<c>ARRAY [0..12] OF TIME</c>) begins <c>0, 10, 20</c> ms.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(TwinCATDataType.Time)]
|
||||
[InlineData(TwinCATDataType.Date)]
|
||||
[InlineData(TwinCATDataType.DateTime)]
|
||||
[InlineData(TwinCATDataType.TimeOfDay)]
|
||||
public void Decode_time_and_date_elements_as_uint(TwinCATDataType type)
|
||||
=> TwinCATArrayCodec.Decode(type, [0, 0, 0, 0, 10, 0, 0, 0, 20, 0, 0, 0], new TwinCATArrayLayout(3, 4), utf8: false)
|
||||
.ShouldBe(new uint[] { 0, 10, 20 });
|
||||
|
||||
/// <summary>A BOOL element is one byte; any non-zero byte is TRUE (live: <c>.ARGTYPE_IS_BIT_TYPE</c>).</summary>
|
||||
[Fact]
|
||||
public void Decode_bool_elements_one_byte_each_non_zero_is_true()
|
||||
=> TwinCATArrayCodec.Decode(TwinCATDataType.Bool, [0, 1, 1, 0xFF, 0], new TwinCATArrayLayout(5, 1), utf8: false)
|
||||
.ShouldBe(new[] { false, true, true, true, false });
|
||||
|
||||
/// <summary>
|
||||
/// Each STRING(n) element sits at a fixed n+1 stride and ends at ITS first NUL — the stale tail a
|
||||
/// longer earlier value left (live on Z33856) never leaks into the element, nor into the next one.
|
||||
/// Windows-1252 unless the target reports UTF-8, exactly like a scalar string.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Decode_string_elements_at_their_stride_each_ending_at_its_first_nul()
|
||||
{
|
||||
var bytes = new byte[3 * 6]; // STRING(5)
|
||||
"RUN"u8.CopyTo(bytes.AsSpan(0));
|
||||
bytes[4] = (byte)'T'; // stale tail after the NUL
|
||||
"MON_O"u8.CopyTo(bytes.AsSpan(6)); // full-length, no NUL before the stride end
|
||||
bytes[12] = 0xB0; // '°' in Windows-1252
|
||||
TwinCATArrayCodec.Decode(TwinCATDataType.String, bytes, new TwinCATArrayLayout(3, 6), utf8: false)
|
||||
.ShouldBe(new[] { "RUN", "MON_O", "°" });
|
||||
|
||||
var utf8 = new byte[2 * 4];
|
||||
"°C"u8.CopyTo(utf8.AsSpan(0));
|
||||
TwinCATArrayCodec.Decode(TwinCATDataType.String, utf8, new TwinCATArrayLayout(2, 4), utf8: true)
|
||||
.ShouldBe(new[] { "°C", "" });
|
||||
}
|
||||
|
||||
/// <summary>Each WSTRING(n) element is UTF-16LE at a 2(n+1) stride, ending at its first aligned U+0000.</summary>
|
||||
[Fact]
|
||||
public void Decode_wstring_elements_at_their_stride()
|
||||
{
|
||||
var bytes = new byte[2 * 8]; // WSTRING(3)
|
||||
Encoding.Unicode.GetBytes("Ab").CopyTo(bytes, 0);
|
||||
Encoding.Unicode.GetBytes("Zürn").CopyTo(bytes, 8); // 4 chars fill the whole stride
|
||||
TwinCATArrayCodec.Decode(TwinCATDataType.WString, bytes, new TwinCATArrayLayout(2, 8), utf8: false)
|
||||
.ShouldBe(new[] { "Ab", "Zürn" });
|
||||
}
|
||||
|
||||
/// <summary>A sub-range layout decodes only its leading elements, even from a longer buffer.</summary>
|
||||
[Fact]
|
||||
public void Decode_honours_the_layout_count_not_the_buffer_length()
|
||||
=> TwinCATArrayCodec.Decode(TwinCATDataType.Int, [1, 0, 2, 0, 3, 0], new TwinCATArrayLayout(2, 2), utf8: false)
|
||||
.ShouldBe(new short[] { 1, 2 });
|
||||
|
||||
/// <summary>A buffer shorter than the layout is a protocol fault, never a silently-short array.</summary>
|
||||
[Fact]
|
||||
public void Decode_refuses_a_buffer_shorter_than_the_layout()
|
||||
=> Should.Throw<ArgumentException>(() =>
|
||||
TwinCATArrayCodec.Decode(TwinCATDataType.Int, [1, 0, 2], new TwinCATArrayLayout(2, 2), utf8: false));
|
||||
|
||||
private static void Decode<T>(TwinCATDataType type, int stride, T[] values, Action<Span<byte>, T> write)
|
||||
{
|
||||
var bytes = new byte[values.Length * stride];
|
||||
for (var i = 0; i < values.Length; i++) write(bytes.AsSpan(i * stride, stride), values[i]);
|
||||
var decoded = TwinCATArrayCodec.Decode(type, bytes, new TwinCATArrayLayout(values.Length, stride), utf8: false);
|
||||
decoded.ShouldBeOfType<T[]>().ShouldBe(values);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Shouldly;
|
||||
using TwinCAT.TypeSystem;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="TwinCATBitAccess"/> — how a bit-indexed BOOL tag (<c>.ADSSTATE_RUN.0</c>) is served by a
|
||||
/// native notification. ADS has no symbol for <c>WORD.N</c>: live on the TwinCAT 2.11 Elma cleaner Z33856
|
||||
/// (2026-10-07) registering the notification on <c>.ADSSTATE_RUN.0</c> failed with
|
||||
/// <c>DeviceSymbolNotFound</c> — and that registration error tore down the WHOLE subscribe call. The
|
||||
/// notification now registers the PARENT word as raw bytes (its resolved width) and extracts the bit.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATBitAccessTests
|
||||
{
|
||||
/// <summary>The parent path is the bit path without its trailing <c>.N</c>; a non-bit path is unchanged.</summary>
|
||||
[Fact]
|
||||
public void ParentPath_strips_the_trailing_bit_index()
|
||||
{
|
||||
TwinCATBitAccess.ParentPath(".ADSSTATE_RUN.0").ShouldBe(".ADSSTATE_RUN");
|
||||
TwinCATBitAccess.ParentPath("MAIN.Status.Flags.15").ShouldBe("MAIN.Status.Flags");
|
||||
TwinCATBitAccess.ParentPath("MAIN.Flags").ShouldBe("MAIN.Flags");
|
||||
}
|
||||
|
||||
/// <summary>Bits are numbered little-endian across the parent's bytes (bit 9 = byte 1, bit 1).</summary>
|
||||
[Fact]
|
||||
public void ReadBit_numbers_bits_little_endian_across_the_parent_bytes()
|
||||
{
|
||||
byte[] word = [0b0000_0101, 0b0000_0010]; // .ADSSTATE_RUN = 5 → bits 0 and 2; plus bit 9
|
||||
TwinCATBitAccess.ReadBit(word, 0).ShouldBeTrue();
|
||||
TwinCATBitAccess.ReadBit(word, 1).ShouldBeFalse();
|
||||
TwinCATBitAccess.ReadBit(word, 2).ShouldBeTrue();
|
||||
TwinCATBitAccess.ReadBit(word, 9).ShouldBeTrue();
|
||||
TwinCATBitAccess.ReadBit(word, 15).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>A bit past the parent's width cannot be read — refused instead of publishing FALSE at Good.</summary>
|
||||
[Fact]
|
||||
public void CheckParent_refuses_a_bit_outside_the_parent_width()
|
||||
{
|
||||
var word = TwinCATPlcSymbolShape.Classify("UINT16", DataTypeCategory.Primitive, 2);
|
||||
TwinCATBitAccess.CheckParent(15, word).ShouldBeNull();
|
||||
TwinCATBitAccess.CheckParent(16, word).ShouldNotBeNull();
|
||||
TwinCATBitAccess.CheckParent(-1, word).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Only an integer word has addressable bits — a string, a float or a container is refused.</summary>
|
||||
[Fact]
|
||||
public void CheckParent_refuses_a_parent_that_is_not_an_integer_word()
|
||||
{
|
||||
TwinCATBitAccess.CheckParent(0, TwinCATPlcSymbolShape.Classify("STRING(80)", DataTypeCategory.String, 81)).ShouldNotBeNull();
|
||||
TwinCATBitAccess.CheckParent(0, TwinCATPlcSymbolShape.Classify("REAL", DataTypeCategory.Primitive, 4)).ShouldNotBeNull();
|
||||
TwinCATBitAccess.CheckParent(0, TwinCATPlcSymbolShape.Classify("ST_X", DataTypeCategory.Struct, 8)).ShouldNotBeNull();
|
||||
TwinCATBitAccess.CheckParent(31, TwinCATPlcSymbolShape.Classify("DWORD", DataTypeCategory.Primitive, 4)).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Native ADS notifications for ARRAY tags. Before this, the notification path registered only the
|
||||
/// declared scalar, so an array tag published its first element at Good; the declared-type guard
|
||||
/// then refused every array tag with <c>BadTypeMismatch</c>. The driver now hands the authored
|
||||
/// <c>ArrayLength</c> to the client, which registers the whole array as raw bytes and decodes it
|
||||
/// (<see cref="TwinCATArrayCodec"/>) — these pin the driver's half of that contract.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATNativeArraySubscribeTests
|
||||
{
|
||||
private const string Host = "ads://5.23.91.23.1.1:851";
|
||||
|
||||
private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewNativeDriver(params TwinCATTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeTwinCATClientFactory();
|
||||
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(Host)],
|
||||
RawTags = TwinCATRawTags.Entries(tags),
|
||||
Probe = new TwinCATProbeOptions { Enabled = false },
|
||||
UseNativeNotifications = true,
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An array tag registers with its authored ArrayLength, and the element-typed array the client
|
||||
/// decodes reaches OnDataChange whole, at Good — the same shape the poll read publishes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Native_subscribe_registers_an_array_tag_with_its_authored_length_and_publishes_the_array()
|
||||
{
|
||||
var (drv, factory) = NewNativeDriver(
|
||||
new TwinCATTagDefinition("Samples", Host, "P_FILTER.SAMPLE", TwinCATDataType.Real, ArrayLength: 20));
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
_ = await drv.SubscribeAsync(["Samples"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
|
||||
|
||||
var reg = factory.Clients[0].Notifications.ShouldHaveSingleItem();
|
||||
reg.Type.ShouldBe(TwinCATDataType.Real);
|
||||
reg.ArrayCount.ShouldBe(20);
|
||||
|
||||
var samples = Enumerable.Range(1, 20).Select(i => i * 1.5f).ToArray();
|
||||
factory.Clients[0].FireNotification("P_FILTER.SAMPLE", samples);
|
||||
var e = events.ShouldHaveSingleItem();
|
||||
e.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
|
||||
e.Snapshot.Value.ShouldBeSameAs(samples);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A scalar tag, an element-indexed path (<c>Arr[3]</c>) and a bit-indexed BOOL stay scalars: no
|
||||
/// array count reaches the client.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Scalar_element_indexed_and_bit_indexed_tags_register_without_an_array_count()
|
||||
{
|
||||
var (drv, factory) = NewNativeDriver(
|
||||
new TwinCATTagDefinition("Count", Host, "MAIN.Count", TwinCATDataType.DInt),
|
||||
new TwinCATTagDefinition("Fault3", Host, "MAIN.FaultCodes[3]", TwinCATDataType.UInt),
|
||||
new TwinCATTagDefinition("Run0", Host, "MAIN.State.0", TwinCATDataType.Bool));
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
_ = await drv.SubscribeAsync(["Count", "Fault3", "Run0"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
|
||||
|
||||
var regs = factory.Clients[0].Notifications;
|
||||
regs.Count.ShouldBe(3);
|
||||
regs.ShouldAllBe(n => n.ArrayCount == null);
|
||||
regs.Single(n => n.SymbolPath == "MAIN.State.0").BitIndex.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>A reconnect replays an array registration with the same array count.</summary>
|
||||
[Fact]
|
||||
public async Task Reconnect_replays_an_array_registration_with_its_array_count()
|
||||
{
|
||||
var (drv, factory) = NewNativeDriver(
|
||||
new TwinCATTagDefinition("Faults", Host, "MAIN.FaultCodes", TwinCATDataType.UInt, ArrayLength: 13));
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
_ = await drv.SubscribeAsync(["Faults"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
|
||||
|
||||
factory.Clients[0].SimulateWireDrop();
|
||||
_ = await drv.ReadAsync(["Faults"], TestContext.Current.CancellationToken);
|
||||
|
||||
factory.Clients.Count.ShouldBe(2);
|
||||
factory.Clients[1].Notifications.ShouldHaveSingleItem().ArrayCount.ShouldBe(13);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A notification the client refuses for a reason other than a type mismatch (an array above the
|
||||
/// native-notification size cap → <c>BadOutOfRange</c>) is isolated the same way: that one tag
|
||||
/// publishes the refusal's own status and the rest of the subscription registers.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_refused_oversized_array_is_isolated_with_the_refusals_own_status()
|
||||
{
|
||||
var (drv, factory) = NewNativeDriver(
|
||||
new TwinCATTagDefinition("Slaves", Host, "P_EC.SLAVEADDRESS", TwinCATDataType.UInt, ArrayLength: 70000),
|
||||
new TwinCATTagDefinition("Count", Host, "MAIN.Count", TwinCATDataType.DInt));
|
||||
factory.Customise = () =>
|
||||
{
|
||||
var c = new FakeTwinCATClient();
|
||||
c.AddNotificationErrors["P_EC.SLAVEADDRESS"] = new TwinCATNotificationRefusedException(
|
||||
"P_EC.SLAVEADDRESS", "140000 B exceeds the 131072 B native-notification cap",
|
||||
TwinCATStatusMapper.BadOutOfRange);
|
||||
return c;
|
||||
};
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
_ = await drv.SubscribeAsync(["Slaves", "Count"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
|
||||
|
||||
factory.Clients[0].Notifications.ShouldHaveSingleItem().SymbolPath.ShouldBe("MAIN.Count");
|
||||
var refused = events.ShouldHaveSingleItem();
|
||||
refused.FullReference.ShouldBe("Slaves");
|
||||
refused.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
|
||||
refused.Snapshot.Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>A type-mismatch refusal is a notification refusal carrying <c>BadTypeMismatch</c>.</summary>
|
||||
[Fact]
|
||||
public void A_type_mismatch_is_a_notification_refusal_carrying_BadTypeMismatch()
|
||||
{
|
||||
TwinCATNotificationRefusedException ex = new TwinCATTypeMismatchException("MAIN.X", "declared DInt over REAL");
|
||||
ex.StatusCode.ShouldBe(TwinCATStatusMapper.BadTypeMismatch);
|
||||
ex.SymbolPath.ShouldBe("MAIN.X");
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -191,13 +191,13 @@ public sealed class TwinCATNativeNotificationTests
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<ITwinCATNotificationHandle> AddNotificationAsync(
|
||||
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime,
|
||||
string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, TimeSpan cycleTime,
|
||||
int maxDelayMs, Action<string, object?> onChange, CancellationToken cancellationToken)
|
||||
{
|
||||
AddCallCount++;
|
||||
if (AddCallCount > _succeedBefore)
|
||||
throw new InvalidOperationException($"fake fail on call #{AddCallCount}");
|
||||
return base.AddNotificationAsync(symbolPath, type, bitIndex, cycleTime, maxDelayMs, onChange, cancellationToken);
|
||||
return base.AddNotificationAsync(symbolPath, type, bitIndex, arrayCount, cycleTime, maxDelayMs, onChange, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,26 @@ public sealed class TwinCATTypeGuardTests
|
||||
TwinCATTypeGuard.CheckRead(TwinCATDataType.String, Plc("ST_LOGDATA", 81, DataTypeCategory.Struct)).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A SCALAR tag over a PLC array has no value the symbol-aware read can convert: live on Z33856
|
||||
/// (2026-10-07) <c>.ARRFAULTCODE</c> (<c>ARRAY [0..12] OF UINT</c>) read as <c>UInt</c> made the Beckhoff
|
||||
/// read throw, surfacing as <c>BadCommunicationError</c> and a Degraded driver, while the native path
|
||||
/// said <c>BadTypeMismatch</c>. Both paths now refuse it with <c>BadTypeMismatch</c> — by category, so an
|
||||
/// alias of an array type is caught too. Other non-scalars (a pointer read as its address) are left
|
||||
/// to the read, as before.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Read_refuses_a_scalar_declared_over_a_plc_array()
|
||||
{
|
||||
TwinCATTypeGuard.CheckRead(TwinCATDataType.UInt, Plc("ARRAY [0..12] OF UINT", 26, DataTypeCategory.Array))
|
||||
.ShouldNotBeNull();
|
||||
TwinCATTypeGuard.CheckRead(TwinCATDataType.DInt, Plc("T_FAULTS", 52, DataTypeCategory.Array)).ShouldNotBeNull();
|
||||
TwinCATTypeGuard.CheckRead(TwinCATDataType.UDInt, Plc("POINTER TO BYTE", 4, DataTypeCategory.Pointer)).ShouldBeNull();
|
||||
Plc("ARRAY [0..12] OF UINT", 26, DataTypeCategory.Array).IsArray.ShouldBeTrue();
|
||||
Plc("ARRAY [0..12] OF UINT", 26).IsArray.ShouldBeTrue(); // by name when no category resolved
|
||||
Plc("UINT", 2, DataTypeCategory.Primitive).IsArray.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 0.19.2: a STRING / WSTRING tag matches a PLC <c>STRING(n)</c> / <c>WSTRING(n)</c> of ANY length.
|
||||
/// A numeric-over-numeric pair is left to the symbol-aware read, which converts the value.
|
||||
|
||||
+21
-2
@@ -144,8 +144,7 @@ public sealed class AbLegacyDriverPageFormSerializationTests
|
||||
public void A_config_without_transport_loads_as_Default_and_an_unrelated_edit_keeps_the_key_absent()
|
||||
{
|
||||
// Task 22 reverses Task 19's "re-emit an explicit Libplctag": that would pin every edited driver to
|
||||
// a concrete transport and bypass the driver-side default (and AbLegacy's PLC-5 / LogixPccc
|
||||
// exclusion). Absent loads as Default (null) and a save that edits something else still omits it.
|
||||
// a concrete transport and bypass the driver-side default (Libplctag since final review I3). Absent loads as Default (null) and a save that edits something else still omits it.
|
||||
var loaded = AbLegacyDriverForm.TryDeserialize("""{"probeTimeoutSeconds":5}""");
|
||||
loaded.ShouldNotBeNull();
|
||||
var form = AbLegacyDriverForm.FormModel.FromOptions(loaded);
|
||||
@@ -168,4 +167,24 @@ public sealed class AbLegacyDriverPageFormSerializationTests
|
||||
form.Transport = null; // the "Default (...)" dropdown option
|
||||
AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices)).ShouldNotContain("transport", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Final review I3: the dropdown labels must say what an absent key does (libplctag) and that Native is
|
||||
/// opt-in. Source scan (no bUnit in this repo); the labels are static option text.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_transport_dropdown_labels_name_libplctag_as_the_default_and_native_as_opt_in()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
|
||||
dir = dir.Parent;
|
||||
dir.ShouldNotBeNull("could not locate the repository root");
|
||||
var razor = File.ReadAllText(Path.Combine(dir!.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.AdminUI",
|
||||
"Components", "Shared", "Drivers", "Forms", "AbLegacyDriverForm.razor"));
|
||||
|
||||
razor.ShouldContain("<option value=\"\">Default (libplctag)</option>");
|
||||
razor.ShouldContain("<option value=\"@AbLegacyTransport.Native\">Native (managed, opt-in)</option>");
|
||||
razor.ShouldContain("<option value=\"@AbLegacyTransport.Libplctag\">libplctag</option>");
|
||||
razor.ShouldNotContain("Default (Native");
|
||||
}
|
||||
}
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Final review I1 — the node-level <c>Drivers:AbCip:DefaultTransport</c>: binding (appsettings and the
|
||||
/// <c>__</c> env-var form), precedence (explicit per-driver key > node default > compiled Native), the
|
||||
/// probe agreeing with the driver factory, and fail-fast validation. Every precedence test runs a pinned
|
||||
/// node (Libplctag) against the compiled default (Native), so a wiring that ignores the node value goes red.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Collection(AbTransportSharedPoolCollection.Name)] // resolves the registry, which configures the shared pool
|
||||
public sealed class AbCipDriverDefaultsOptionsTests
|
||||
{
|
||||
private const string NoKey = """{"devices":[{"hostAddress":"ab://10.0.0.1/1,0"}],"probe":{"enabled":false}}""";
|
||||
private const string ExplicitNative =
|
||||
"""{"transport":"Native","devices":[{"hostAddress":"ab://10.0.0.1/1,0"}],"probe":{"enabled":false}}""";
|
||||
|
||||
private static ServiceProvider Build(IConfiguration configuration)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<ISecretResolver>(new StubSecretResolver());
|
||||
services.AddValidatedOptions<AbCipDriverDefaultsOptions, AbCipDriverDefaultsOptionsValidator>(
|
||||
configuration, AbCipDriverDefaultsOptions.SectionName);
|
||||
services.AddOtOpcUaDriverFactories();
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private static IConfiguration Config(string? value) =>
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(value is null
|
||||
? []
|
||||
: new Dictionary<string, string?> { ["Drivers:AbCip:DefaultTransport"] = value })
|
||||
.Build();
|
||||
|
||||
private static AbCipDriver Create(ServiceProvider sp, string json) =>
|
||||
(AbCipDriver)sp.GetRequiredService<DriverFactoryRegistry>().TryGet(DriverTypeNames.AbCip)!("drv-i1", json);
|
||||
|
||||
[Fact]
|
||||
public void Absent_section_defaults_to_Native()
|
||||
{
|
||||
using var sp = Build(Config(null));
|
||||
sp.GetRequiredService<IOptions<AbCipDriverDefaultsOptions>>().Value.ResolveDefaultTransport()
|
||||
.ShouldBe(AbCipTransport.Native);
|
||||
Create(sp, NoKey).EffectiveTransport.ShouldBe(AbCipTransport.Native);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Libplctag", AbCipTransport.Libplctag)]
|
||||
[InlineData("libplctag", AbCipTransport.Libplctag)]
|
||||
[InlineData("NATIVE", AbCipTransport.Native)]
|
||||
[InlineData("", AbCipTransport.Native)]
|
||||
public void The_appsettings_key_binds(string value, AbCipTransport expected)
|
||||
{
|
||||
using var sp = Build(Config(value));
|
||||
sp.GetRequiredService<IOptions<AbCipDriverDefaultsOptions>>().Value.ResolveDefaultTransport()
|
||||
.ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>The documented env-var override form, <c>Drivers__AbCip__DefaultTransport</c>.</summary>
|
||||
[Fact]
|
||||
public void The_double_underscore_environment_variable_binds()
|
||||
{
|
||||
const string prefix = "OTOPCUA_I1_TEST_";
|
||||
Environment.SetEnvironmentVariable(prefix + "Drivers__AbCip__DefaultTransport", "Libplctag");
|
||||
try
|
||||
{
|
||||
var configuration = new ConfigurationBuilder().AddEnvironmentVariables(prefix).Build();
|
||||
using var sp = Build(configuration);
|
||||
Create(sp, NoKey).EffectiveTransport.ShouldBe(AbCipTransport.Libplctag);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(prefix + "Drivers__AbCip__DefaultTransport", null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A pinned node moves no-key drivers; an explicit per-driver key still wins over the pin.</summary>
|
||||
[Fact]
|
||||
public void Node_default_applies_only_to_drivers_without_a_key()
|
||||
{
|
||||
using var sp = Build(Config("Libplctag"));
|
||||
Create(sp, NoKey).EffectiveTransport.ShouldBe(AbCipTransport.Libplctag);
|
||||
Create(sp, ExplicitNative).EffectiveTransport.ShouldBe(AbCipTransport.Native);
|
||||
}
|
||||
|
||||
/// <summary>The Test Connect probe resolves an absent key to the SAME node default the driver does.</summary>
|
||||
[Theory]
|
||||
[InlineData("Libplctag", AbCipTransport.Libplctag)]
|
||||
[InlineData(null, AbCipTransport.Native)]
|
||||
public void The_probe_uses_the_node_default(string? value, AbCipTransport expected)
|
||||
{
|
||||
using var sp = Build(Config(value));
|
||||
var probe = sp.GetServices<IDriverProbe>().OfType<AbCipDriverProbe>().ShouldHaveSingleItem();
|
||||
probe.DefaultTransport.ShouldBe(expected);
|
||||
Create(sp, NoKey).EffectiveTransport.ShouldBe(expected); // driver and probe agree
|
||||
}
|
||||
|
||||
/// <summary>The probe stays registered exactly once on a fused node (driver path + admin path).</summary>
|
||||
[Fact]
|
||||
public void The_factory_built_probe_is_still_deduplicated()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOtOpcUaDriverProbes();
|
||||
services.AddOtOpcUaDriverProbes();
|
||||
using var sp = services.BuildServiceProvider();
|
||||
sp.GetServices<IDriverProbe>().OfType<AbCipDriverProbe>().Count().ShouldBe(1);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Bogus")]
|
||||
[InlineData("1")]
|
||||
[InlineData("7")]
|
||||
[InlineData("Native,Libplctag")]
|
||||
public void A_bad_value_fails_validation_with_a_named_message(string value)
|
||||
{
|
||||
var result = new AbCipDriverDefaultsOptionsValidator().Validate(
|
||||
null, new AbCipDriverDefaultsOptions { DefaultTransport = value });
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain(f => f.Contains("Drivers:AbCip:DefaultTransport") && f.Contains(value));
|
||||
|
||||
using var sp = Build(Config(value));
|
||||
Should.Throw<OptionsValidationException>(
|
||||
() => sp.GetRequiredService<IOptions<AbCipDriverDefaultsOptions>>().Value)
|
||||
.Message.ShouldContain("Drivers:AbCip:DefaultTransport");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Native")]
|
||||
[InlineData("libplctag")]
|
||||
[InlineData(null)]
|
||||
[InlineData(" ")]
|
||||
public void A_good_or_blank_value_validates(string? value) =>
|
||||
new AbCipDriverDefaultsOptionsValidator()
|
||||
.Validate(null, new AbCipDriverDefaultsOptions { DefaultTransport = value })
|
||||
.Succeeded.ShouldBeTrue();
|
||||
|
||||
private sealed class StubSecretResolver : ISecretResolver
|
||||
{
|
||||
public Task<string?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult<string?>(null);
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
|
||||
|
||||
/// <summary>The shared pool is process-wide, so the tests that touch it must not run in parallel.</summary>
|
||||
[CollectionDefinition(Name, DisableParallelization = true)]
|
||||
public sealed class AbTransportSharedPoolCollection
|
||||
{
|
||||
public const string Name = "AbTransportSharedPool";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Final review I2: <c>DeviceSessionPool.Shared</c> must log through the HOST logger factory, on driver
|
||||
/// nodes (via the driver registry) and on admin-only nodes (via the AbCip / AbLegacy Test Connect probes).
|
||||
/// Each test first resets the pool to a null logger and asserts that baseline, so deleting the wiring
|
||||
/// call turns it red (the logger would stay the reset one).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Collection(AbTransportSharedPoolCollection.Name)]
|
||||
public sealed class AbTransportSharedLoggerWiringTests
|
||||
{
|
||||
private static RecordingLoggerFactory Reset()
|
||||
{
|
||||
DeviceSessionPool.ConfigureSharedLogger(NullLoggerFactory.Instance);
|
||||
var factory = new RecordingLoggerFactory();
|
||||
AssertPoolLogsTo(factory, expected: false); // baseline: the wiring under test has not run yet
|
||||
return factory;
|
||||
}
|
||||
|
||||
// Logger<T> wraps the factory's logger, so identity cannot be compared: emit through the pool's logger
|
||||
// and check whether it lands in the recording factory under the pool's category.
|
||||
private static void AssertPoolLogsTo(RecordingLoggerFactory factory, bool expected)
|
||||
{
|
||||
var marker = Guid.NewGuid().ToString("N");
|
||||
DeviceSessionPool.Shared.Logger.LogWarning("wiring-probe {Marker}", marker);
|
||||
factory.Messages(typeof(DeviceSessionPool).FullName!).Any(m => m.Contains(marker)).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolving_the_driver_registry_wires_the_pool_to_the_host_logger_factory()
|
||||
{
|
||||
var factory = Reset();
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<ILoggerFactory>(factory);
|
||||
services.AddSingleton<ISecretResolver>(new StubSecretResolver());
|
||||
services.AddOtOpcUaDriverFactories();
|
||||
using var sp = services.BuildServiceProvider();
|
||||
|
||||
sp.GetRequiredService<DriverFactoryRegistry>();
|
||||
|
||||
AssertPoolLogsTo(factory, expected: true);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DriverTypeNames.AbCip)]
|
||||
[InlineData(DriverTypeNames.AbLegacy)]
|
||||
public void An_admin_only_node_wires_the_pool_through_the_AB_probes(string driverType)
|
||||
{
|
||||
var factory = Reset();
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<ILoggerFactory>(factory);
|
||||
services.AddOtOpcUaDriverProbes(); // the admin-only path: no driver registry
|
||||
using var sp = services.BuildServiceProvider();
|
||||
|
||||
sp.GetServices<IDriverProbe>().Single(p => p.DriverType == driverType);
|
||||
|
||||
AssertPoolLogsTo(factory, expected: true);
|
||||
}
|
||||
|
||||
private sealed class RecordingLoggerFactory : ILoggerFactory
|
||||
{
|
||||
private readonly List<(string Category, string Message)> _messages = [];
|
||||
|
||||
public IReadOnlyList<string> Messages(string category)
|
||||
{
|
||||
lock (_messages) return [.. _messages.Where(m => m.Category == category).Select(m => m.Message)];
|
||||
}
|
||||
|
||||
public ILogger CreateLogger(string categoryName) => new Recording(this, categoryName);
|
||||
public void AddProvider(ILoggerProvider provider) { }
|
||||
public void Dispose() { }
|
||||
|
||||
private sealed class Recording(RecordingLoggerFactory owner, string category) : ILogger
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
lock (owner._messages) owner._messages.Add((category, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubSecretResolver : ISecretResolver
|
||||
{
|
||||
public Task<string?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult<string?>(null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user