Files
lmxopcua/docs/drivers/AbCip.md
T

14 KiB

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. For the manual test client, see Driver.AbCip.Cli.md. For the integration fixture coverage map, see 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.
  • 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.

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 for the coverage map and the AB_SERVER_ENDPOINT wiring.
  • Manual client — 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 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 still undetectable.
  • 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.