Files
lmxopcua/docs/drivers/AbCip.md
T

332 lines
25 KiB
Markdown

# AB CIP Driver
In-process native-protocol driver that exposes Allen-Bradley CIP / EtherNet-IP
controllers as OPC UA nodes. It runs inside the OtOpcUa server's .NET 10 AnyCPU
process and talks to the PLC through the libplctag.NET wrapper — no gateway, no
sidecar. One driver instance can serve many devices; per-device routing is keyed
on the canonical `ab://gateway[:port]/cip-path` host-address string.
Supported families: **ControlLogix**, **CompactLogix**, **Micro800**, and
**GuardLogix**. CIP has no native push model, so subscriptions are a polling
overlay on top of `IReadable`.
For the driver spec (capability surface, config shape, type mapping), see
[docs/v2/driver-specs.md §3](../v2/driver-specs.md). For the manual test client,
see [Driver.AbCip.Cli.md](../Driver.AbCip.Cli.md). For the integration fixture
coverage map, see [AbServer-Test-Fixture.md](AbServer-Test-Fixture.md).
## Project Layout
| Project | Role |
|---------|------|
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/` | The driver — `AbCipDriver`, the libplctag runtime/enumerator/template-reader wrappers, the UDT read planner + template decoders, the host-address parser, and the ALMD alarm projection. |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/` | `AbCipDriverOptions`, `AbCipDeviceOptions`, `AbCipTagDefinition` / `AbCipStructureMember`, and the `AbCipDataType` / `AbCipPlcFamily` enums bound from the driver's `DriverConfig` JSON. |
Per family the `AbCipPlcFamilyProfile` (`PlcFamilies/AbCipPlcFamilyProfile.cs`)
supplies the libplctag `plc` attribute, default CIP path, ConnectionSize, and
request-packing / connected-messaging quirks — ControlLogix is the baseline and
each other family is a delta (Micro800 is unconnected-only with no backplane
routing; GuardLogix shares the ControlLogix wire protocol with a tag-level safety
partition).
## Capability Surface
`AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable`
(`Driver.AbCip/AbCipDriver.cs`). It adds **`IAlarmSource`** over the Modbus /
AB Legacy surface.
| Capability | Implementation entry point | Notes |
|------------|---------------------------|-------|
| `ITagDiscovery` | `DiscoverAsync` | Emits pre-declared tags under per-device folders; UDT tags with declared `Members` fan out into a sub-folder + one variable per member. With `EnableControllerBrowse` the `@tags` symbol table — plus each `Program:<name>.@tags` listing — is walked into a `Discovered/` folder (system/module/routine tags filtered out). |
| `IReadable` | `ReadAsync` → `ReadGroupAsync` / `ReadSingleAsync` | Per-tag reads; opt-in whole-UDT grouping (`EnableDeclarationOnlyUdtGrouping`) collapses N member reads into one. |
| `IWritable` | `WriteAsync` | BOOL-within-DINT writes do a per-parent read-modify-write under a lock; `SafetyTag` and non-writable tags return `BadNotWritable`. |
| `ISubscribable` | `SubscribeAsync` driven by the shared `PollGroupEngine` | CIP has no push model — subscriptions become polling groups. |
| `IHostConnectivityProbe` | `ProbeLoopAsync` + `GetHostStatuses` | One probe loop per device reading `Probe.ProbeTagPath`; no path configured ⇒ a warning is logged and the device stays `Unknown`. |
| `IPerCallHostResolver` | `ResolveHost` | Routes each call to the tag's `DeviceHostAddress`, the breaker key for the resilience pipeline so one dead PLC trips only its own breaker. |
| `IAlarmSource` | `AbCipAlarmProjection` (ALMD) | Opt-in via `EnableAlarmProjection`; off by default the subscribe path is a no-op so capability negotiation still works. |
## Addressing Model
Per-device host addresses are the canonical `ab://gateway[:port]/cip-path` form
parsed by `AbCipHostAddress.TryParse` (`AbCipHostAddress.cs`). The parsed
`CipPath` is handed to libplctag verbatim, so no wire-layer translation is
needed:
| Form | Meaning |
|------|---------|
| `ab://10.0.0.5/1,0` | Single-chassis ControlLogix, CPU in slot 0 |
| `ab://10.0.0.5/1,2,2,192.168.50.20,1,0` | Bridged ControlLogix (routed path) |
| `ab://10.0.0.5/` | Micro800 / no-backplane device (empty path) |
| `ab://10.0.0.5:44818/1,0` | Explicit EIP port (default 44818) |
Tags carry a Logix symbolic `TagPath` (controller or program scope). UDT-typed
tags are declared as `AbCipDataType.Structure` with a `Members` list; discovery
fans each member out as `{tag.Name}.{member.Name}`, and the read planner can
collapse a batch of members into one whole-UDT read when
`EnableDeclarationOnlyUdtGrouping` is set. The whole-UDT fast path is opt-in
because Studio 5000 may reorder members vs declaration order; decoding at
declaration-order offsets against a reordered layout yields silently-plausible
wrong numbers.
## Configuration
`AbCipDriverOptions` (`Driver.AbCip.Contracts/AbCipDriverOptions.cs`) binds from
the driver's `DriverConfig` JSON. Key fields:
- **`Devices`** — one `AbCipDeviceOptions` per PLC (`HostAddress`, `PlcFamily`, optional `DeviceName`, per-device `AllowPacking` / `ConnectionSize` overrides).
- **`Tags`** — pre-declared `AbCipTagDefinition` list; `Members` for UDT fan-out, `SafetyTag` for GuardLogix safety-partition tags.
- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeTagPath`.
- **`Transport`** — absent (default ⇒ `Native`), `Native`, or `Libplctag` (fallback); see [Transport](#transport-transport-option).
- **Discovery** — `EnableControllerBrowse` (`@tags` walk) and `EnableDeclarationOnlyUdtGrouping` (whole-UDT read fast path).
- **Alarms** — `EnableAlarmProjection` + `AlarmPollInterval` for the ALMD projection.
Full per-field descriptions live in `AbCipDriverOptions.cs`. The JSON skeleton is
reproduced in [docs/v2/driver-specs.md §3](../v2/driver-specs.md).
## Transport (`transport` option)
The driver has two interchangeable wire transports, selected by one `DriverConfig` key:
| Key | Values | Default | Serialization |
|---|---|---|---|
| `transport` | absent \| `Native` \| `Libplctag` | absent ⇒ the node's `Drivers:AbCip:DefaultTransport` (itself default `Native`) | string, case-insensitive on read, written as `"Libplctag"` / `"Native"`; "use the default" is encoded by OMITTING the key |
- **`Native`** (the default since Task 22) is a managed in-process EtherNet/IP + CIP client
(`Driver.Ab.Transport`).
- **`Libplctag`** (fallback) drives the libplctag native library, as before.
- An absent key means "use the default". It is resolved in one place, `AbCipTransportResolver`, which the
driver, the Test Connect probe and the CLI all go through. **Precedence:** an explicit per-driver key →
the node-level appsetting **`Drivers:AbCip:DefaultTransport`** (`Native` | `Libplctag`, env
`Drivers__AbCip__DefaultTransport`; a bad value fails host start; see
[Configuration.md](../Configuration.md#driversabcip-node-level-abcip-defaults)) → compiled `Native`. The
Test Connect probe uses the same node value, so it probes the stack the driver would run. The CLI has no
node config and uses compiled `Native`.
- A bad value fails like a bad `PlcFamily`; the Test Connect probe reports "Config JSON is invalid".
- A change takes effect when the driver instance is **constructed**: on every process start (so the upgrade
restart moves no-key drivers to Native — see the release note) and when a deploy that changes `DriverConfig`
does this (`DriverHostActor` respawns the child, #516). `ReinitializeAsync` alone keeps the transport
it was constructed with.
- AdminUI: the **Transport** dropdown in the advanced section of the AbCip driver form ("Default (node
setting; Native unless pinned)" / "Native (managed)" / "libplctag (fallback)"). **Default omits the key on save**, so a defaulted driver stays
defaulted; saving another field preserves an explicit value and preserves absence.
- The driver logs `transport=<effective> (default|configured)` at Information on every init, e.g.
`transport=Native (default)` or `transport=Libplctag (node default)`, including reconnect re-inits. AbCip
drivers now receive the host logger (they previously ran on a null logger, so none of their warnings
reached the node log); expect previously invisible warnings, e.g. a probe enabled with no probe tag path.
- CLI: `--transport default|native|libplctag` (default `default`, which goes through the same resolver).
- libplctag is confined to `Libplctag*.cs` adapters, enforced by a source-scan guard
(`LibplctagConfinementGuardTests`), so removing it later is mechanical.
### Release note — native is the default (Task 22)
- **The native managed transport is now the default.** A driver config with no `transport` key runs
`Native`; libplctag remains available as the fallback.
- ⚠️ **Client-visible:** native reports **`BadTypeMismatch`** for a mis-authored same-width type (for example a
tag authored `DINT` where the controller holds a `REAL`), where libplctag returned Good garbage. Correct the
authored type; see [Native transport behaviour](#native-transport-behaviour). It does not degrade driver
health or churn the connection; one Warning per tag names it.
- **To fall back** one driver, set `"transport": "Libplctag"` in its driver config (or pick "libplctag
(fallback)" in the AdminUI dropdown). To fall back a whole node, set `Drivers:AbCip:DefaultTransport` to
`Libplctag` (below).
- ⚠️ **When it takes effect: the upgrade restart itself.** Every process start constructs the drivers
(including boot-from-cache on a driver-only node), so **every driver with no `transport` key moves to the
node default — `Native` unless pinned — the moment the upgraded node starts**; no deploy is needed. After the
upgrade, a per-driver transport change takes effect when a deploy recreates the driver (not on
`ReinitializeAsync`).
- **To stay on libplctag across the upgrade (pin before upgrading):** set the node-level key on each node
you upgrade, in the same change that installs the new build — `appsettings.json`
`"Drivers": { "AbCip": { "DefaultTransport": "Libplctag" } }`, or the environment variable
`Drivers__AbCip__DefaultTransport=Libplctag` on the service. Older builds ignore the section, so it can be
staged ahead of the binaries. Every AbCip driver with no `transport` key then starts on libplctag; the init
log shows `transport=Libplctag (node default)`. Move to Native later per driver (explicit
`"transport": "Native"` in the new AdminUI) or for the node (remove the key / set `Native`, then restart).
⚠️ Do **not** rely on adding `"transport": "Libplctag"` to driver configs through the **old** AdminUI before
upgrading: its typed AbCip form has no Transport field and drops unknown keys on save (#576), and on a fused
`admin,driver` node the AdminUI and drivers upgrade together, so there is no window. The node key works on
every node shape, including fused nodes and driver-only nodes booting from cache.
- **Evidence, and its limits:** AbCip **reads** (plus browse and templates) were proven on a production
CompactLogix 5069-L320ERS2 (**Z-35162**, read-only, 2026-10-08; see
[gate results](../plans/2026-10-07-native-ab-transport-gate-results.md) §Real press). AbCip **writes** are
simulator-proven only (`ab_server`).
- **AbLegacy is different: its default stays libplctag** and Native is opt-in per driver; the native PCCC
path has wire evidence (on `ab_server`) only for N-file reads/writes and one F8 read (see
[AbLegacy.md §Transport](AbLegacy.md#transport-transport-option)).
## Native transport behaviour
- **One CIP connection and one request scheduler per device** (`DeviceSessionPool`), shared by reads,
writes, browse and template reads. The Multiple Service Packet scheduler coalesces requests with one
request in flight; large reads (listing pages, Read Template) are sent unpacked and exclusive.
**Packing budgets the expected reply as well as the request (#582):** each queued request declares an
upper bound on its reply (`expectedReplyBytes`; reads: elements x element width, STRING = 88 bytes per
element, BOOL counted as 4) and a batch is closed when the summed MSP reply (`6 + 2n + sum`) would exceed
the connection size. A read whose size cannot be bounded (structure / unknown authored type) is sent
alone; writes and status-only replies count their 4-byte header. Pinned by
`RequestSchedulerTests.Mixed_scalar_string_and_array_replies_are_packed_within_the_reply_budget`.
⚠️ RESIDUAL: mixed STRING/array packing has not been exercised against a real controller (the real-press
gate predates the budget); the budget is unit-tested against the scheduler and fake channel only.
- **Fragmented-read integrity (#567):** `Read Tag Fragmented` replies are compared against the first reply;
a changed type code *or* structure handle mid-read fails the read with `BadReply` rather than stitching
two types into one value.
- **Forward Open throttle:** at most one attempt in flight, at least 1 s between starts, bounded by the
open budget.
- `ConnectionSize` / `Timeout` / `AllowPacking` are honoured per pooled connection, and the **first**
driver/device to open a given device key wins — including a Test Connect probe on a fused `admin,driver`
node (its connection carries the probe timeout). A later opener with different options is ignored and
logged once as a Warning (`DeviceSessionPool`). ⚠️ The device key compares the **host string**, so the
same controller addressed by DNS name in one driver and by IP in another gets two connections and two
Forward Open throttles (up to two opens per second); address a controller the same way everywhere. ControlLogix, GuardLogix and (since T21) CompactLogix default to 4002, a Large
Forward Open (0x5B), the same as libplctag. A target that refuses Large gets one regular open at
min(size, 504) inside the same throttled attempt. An explicit `ConnectionSize` in the options or on the
device overrides the family default.
- **Routing:** Micro800 is connected with an empty route regardless of `UseConnectedMessaging`.
SLC500/MicroLogix/PLC-5 over this driver are direct. `logixpccc` uses UnconnectedSend; its SLC framing is
**unverified**.
- ⚠️ **Type check (client-visible change).** The native path compares the type code the controller returns
with the authored `DataType` and reports **`BadTypeMismatch`** on disagreement. libplctag never checks it,
so a same-width mis-authored tag read as **Good garbage**: authoring a tag as `DINT` while the controller
holds a `REAL` returns the REAL's bits as an integer with a Good status under libplctag, and
`BadTypeMismatch` natively. That is the fix working, but a consumer will see Bad quality on tags that
previously looked healthy: correct the authored type. Same-width signed/unsigned pairs are exempt, and
writes are gated on a mismatch too. A mismatch is a **deterministic configuration refusal**, handled like
an Unsupported refusal (final review I4): the tag's handle is kept (no re-create, no reconnect), driver
health is unchanged (no `Degraded`, nothing fed to the comms-loss watchdog), and the Warning is logged
**once per tag** per driver instance, not every poll. The same applies to the libplctag-path width check.
- Malformed tag names put the device into backoff, as under libplctag (#570). STRING capacity includes the
alignment pad (84, not 82) (#570).
- **A refused operation is not a comms fault.** Any operation a runtime refuses as Unsupported (in AbCip, e.g.
a native write whose type cannot be determined) reaches the client as `BadNotSupported`, keeps the tag
handle, and leaves driver health unchanged; only real transport failures evict and degrade.
## Known gaps
- ⚠️ **Native writes are verified against `ab_server` only (simulator-only), never a real controller.** The
real-press gate (Task 21) was **read-only**: it proved native reads, browse and templates on Z-35162
(2026-10-08) and nothing else. Results:
[native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md).
- **libplctag cannot be deleted** until a bench controller allows write verification.
- ⚠️ RESIDUAL (#567): wide-struct **fragmented writes** (8-byte alignment) are unit-tested only. Production
presses are read-only and there is no bench controller, so this cannot be proven; it stays open until a
bench controller exists.
- Native browse: listing pages are unpaced, and template reads outside an enumeration open a connection per
call (#572).
- Micro800, routed and `logixpccc` paths are covered by `ab_server` only. The Micro800 profile passes
natively. The AbLegacy default excludes `logixpccc` (and PLC-5).
- Follow-up issues for this workstream: #564 to #577.
- Fixed along the way (pre-existing on master): `ab://host:44818/` with an empty CIP path (Micro800,
unrouted MicroLogix) failed on libplctag with `ErrorBadData` because the adapter sent `Path=""`; it now
sends no path.
## Alarm Projection
`IAlarmSource` is served by `AbCipAlarmProjection`, which polls each subscribed
ALMD UDT's `InFaulted` + `Severity` members at `AlarmPollInterval` and fires
`OnAlarmEvent` on raise/clear transitions. It is **ALMD-only** in this pass (ALMA
analog alarms are a follow-up) and **disabled by default** — shops running FT
Alarm & Events should keep it off and take alarms through the native route, since
the projection semantics don't exactly mirror Rockwell FT A&E.
## Testing
- **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/` cover the driver, host-address parser, UDT planner, and alarm projection via fake tag runtimes.
- **Integration tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/` run against the `ab_server` Docker fixture. See [AbServer-Test-Fixture.md](AbServer-Test-Fixture.md) for the coverage map and the `AB_SERVER_ENDPOINT` wiring.
- **Manual client** — [Driver.AbCip.Cli.md](../Driver.AbCip.Cli.md).
## 1-D array support
An AB CIP 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.
**Read mechanism** — the driver delegates to **libplctag's native array read**. The tag's
`TagPath` is passed to libplctag as a `[N]`-subscripted symbol (or the tag is created with
an element count of N so libplctag fetches the full block in one EIP/CIP request). Both
atomic types (`DINT[N]`, `REAL[N]`, …) and UDT member arrays are supported.
**Unit test coverage** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/` covers
array reads via the fake tag runtime for atomic and UDT member cases. The `ab_server`
integration fixture is down during normal dev (Docker host fixture currently offline).
**Live-verify** — integration-fixture-gated (requires `ab_server` Docker fixture on `10.100.0.35:44818`).
**Deferrals** — array *writes*, multi-dimensional arrays, per-element historization. Safety-partition array tags (`SafetyTag: true`) are excluded (GuardLogix write constraints extend to array writes).
See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix
and the UI authoring flow.
## Controller-discovered UDT member variables
When `EnableControllerBrowse` is set, the driver walks the controller's `@tags` symbol table and
surfaces each UDT-typed tag's **atomic member leaves** as individually addressable OPC UA
Variables — in addition to the UDT container tag itself.
### What is discovered
- **Top-level UDT members** — the Template Object for each controller-browse UDT is fetched via
its template instance ID; each atomic member (BOOL, SINT, INT, DINT, REAL, …) becomes a
separate Variable node under the UDT's `Discovered/` sub-folder, addressable by its member
path (e.g. `MyUdt.Temperature`, `MyUdt.Flags`). Top-level atomic member discovery is
**functional in production**.
- **Nested struct members** — **shipped** (commits 3d8ce4e8 / d203f31c). When a UDT member is
itself a struct, the Template Object member block carries the nested UDT's template instance ID
in the low 12 bits of the member `info` field (struct flag `0x8000` set). The driver captures
this as `AbCipUdtMember.NestedTemplateId` and threads it into a second `FetchUdtShapeAsync`
call (`@udt/{id}`) so the nested struct's atomic leaves are expanded into dot-joined addressable
paths (e.g. `Motor1.Status.Code`, `Motor1.Status.Running`). Recursion is bounded by a
**depth cap of 8 levels**; members at or beyond the cap are dropped rather than emitted.
Nested struct leaves that resolve to no emittable atomic leaf produce no sub-folder (lazy
materialisation).
`ab_server` (the default Docker simulator) does not implement the CIP Template Object service
(class 0x6C), so the fixture cannot exercise it; see
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateNestedUdtTests.cs`
for the skip-gated Emulate smoke (backlog #6).
- **STRING tags and members** — a controller reports every STRING (built-in, or a user string
type such as `STR0016`) as a `LEN` DINT + `DATA` SINT[n] struct. Discovery recognises that
shape and emits **one String variable**, top-level or nested, instead of a LEN/DATA folder.
- **Hidden members are skipped** — the `ZZZZZZZZZZ…` hosts Logix packs BOOL members into and
`__…` AOI backing members are not browsed (a user name can never start with `__`).
- **Arrays** — the Symbol Object's dimension bits populate `IsArray`/`ElementCount` (multi-dim
flattened). An array of a UDT is emitted as one variable, not fanned out (`Tag.Member` names no
element); an array of STRING is a String array.
- **Program scope** — `@tags` lists each program (`Program:Main`) but none of its tags; the
enumerator reads every `Program:<name>.@tags` listing too, so program-scoped tags appear as
`Program:<name>.<tag>`. One unreadable program listing is skipped, not fatal.
### Real-controller verification (2026-10-07)
Run read-only against production CompactLogix 5069-L320ERS2 rev 34.11 presses (and a second
CompactLogix) from the Mac over VPN. Before the fixes above, every real `@udt/<id>` buffer
decoded to `null` (the decoder assumed a layout no controller sends — the test builders shared
the assumption, the #468 trap), program-scoped tags were absent, and arrays browsed as scalars.
After them, one browse of a press took ~30 s and produced 23,412 variables covering all 80
tags the Ignition project reads on it, with matching types. The template and symbol layouts are
pinned by bytes captured off those controllers: `CipTemplateObjectWireCaptureTests`,
`CipSymbolObjectRealControllerCaptureTests`, `AbCipProgramScopeEnumerationTests`.
### Bare-container reads
Reading the UDT container tag itself (without a member suffix) returns
`BadNotSupported` — address the atomic member leaves instead.
### Member writes
UDT member **writes** discovered via controller browse are **deferred** in this release.
Pre-declared `Members` with `Writable: true` retain full read/write support as before.
### Configuration
No new configuration keys are required. Set `EnableControllerBrowse: true` in the
`AbCipDriverOptions` JSON to enable the `@tags` walk; UDT member expansion is automatic for all
UDT-typed tags found in the walk.
## Operational Notes
- **Native heap is invisible to the GC.** libplctag's native `Tag` heap does not show up in CLR allocation figures. Watch whole-process RSS, and use `ReinitializeAsync` (tears down + re-creates every device's libplctag handles) as the remediation for native-heap growth. (There is no per-driver footprint number to consult: `IDriver.GetMemoryFootprint()` was deleted as unconsumed in Gitea #525.)
- **Handle eviction on failure** — a non-zero libplctag status or a transport exception evicts the cached tag runtime so the next read/write re-creates a fresh handle, mirroring the probe loop's recreate-on-failure behaviour.
- **Read statuses carry the libplctag cause** — a tag the controller does not have reads `BadNodeIdUnknown` (not `BadCommunicationError`), and it does **not** open the device's connect-backoff window (the controller answered; before this, one typo throttled every sibling tag on the device). A genuine connection failure still opens it.
- **Width check on scalar reads** — libplctag never checks the CIP type the controller returns. A scalar atomic read whose buffer is not the authored type's width (e.g. a REAL authored as INT — the legacy Kepware export does exactly this for some press tags) reads `BadTypeMismatch` instead of Good garbage. Same-width mistakes (DINT vs REAL) are undetectable under libplctag; the `Native` transport reports them as `BadTypeMismatch` (see [Native transport behaviour](#native-transport-behaviour)).
- **Module-defined I/O tags** — `Local:1:I.Data`, `ABB_OmniCore:I.ConnectionFaulted` and similar colon-bearing base names are valid tag paths (controller scope only).
- **User string arrays** — `STR0016[N]` and similar arrays stride by the actual element size (read buffer ÷ count); libplctag.NET 1.5.2 leaves `ElementSize` unset after a read.
- **Declaration-only UDT grouping is a footgun unless verified** — only enable `EnableDeclarationOnlyUdtGrouping` when every UDT's member declaration order has been hand-verified against the controller's compiled layout.