19 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— oneAbCipDeviceOptionsper PLC (HostAddress,PlcFamily, optionalDeviceName, per-deviceAllowPacking/ConnectionSizeoverrides).Tags— pre-declaredAbCipTagDefinitionlist;Membersfor UDT fan-out,SafetyTagfor GuardLogix safety-partition tags.Probe— connectivity-probeEnabled/Interval/Timeout/ProbeTagPath.Transport—Libplctag(default) orNative; see Transport.- Discovery —
EnableControllerBrowse(@tagswalk) andEnableDeclarationOnlyUdtGrouping(whole-UDT read fast path). - Alarms —
EnableAlarmProjection+AlarmPollIntervalfor the ALMD projection.
Full per-field descriptions live in AbCipDriverOptions.cs. The JSON skeleton is
reproduced in docs/v2/driver-specs.md §3.
Transport (transport option)
The driver has two interchangeable wire transports, selected by one DriverConfig key:
| Key | Values | Default | Serialization |
|---|---|---|---|
transport |
Libplctag | Native |
Libplctag |
string, case-insensitive on read, written as "Libplctag" / "Native" |
Libplctag(default) drives the libplctag native library, as before.Nativeis a managed in-process EtherNet/IP + CIP client (Driver.Ab.Transport). It is opt-in: the default staysLibplctaguntil the real-press read-only gate has run and the default is flipped.- 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 recreated. A deploy that changes
DriverConfigdoes this (DriverHostActorrespawns the child, #516).ReinitializeAsyncalone keeps the transport it was constructed with. - AdminUI: the Transport dropdown in the advanced section of the AbCip driver form ("libplctag (default)" / "Native (managed)"). Saving another field preserves it.
- The driver logs
transport=<X>at Information on every init, 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 native|libplctag(defaultlibplctag). - libplctag is confined to
Libplctag*.csadapters, enforced by a source-scan guard (LibplctagConfinementGuardTests), so removing it later is mechanical.
Native transport behaviour
- One CIP connection and one request scheduler per device (
DeviceSessionPool), shared by reads, writes, browse and template reads. The Multiple Service Packet scheduler coalesces requests with one request in flight; large reads (listing pages, Read Template) are sent unpacked and exclusive. - Forward Open throttle: at most one attempt in flight, at least 1 s between starts, bounded by the open budget.
ConnectionSize/Timeoutare 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 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 explicitConnectionSizein 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.logixpcccuses UnconnectedSend; its SLC framing is unverified. - ⚠️ Type check (client-visible change). The native path compares the type code the controller returns
with the authored
DataTypeand reportsBadTypeMismatchon disagreement. libplctag never checks it, so a same-width mis-authored tag read as Good garbage: authoring a tag asDINTwhile the controller holds aREALreturns the REAL's bits as an integer with a Good status under libplctag, andBadTypeMismatchnatively. 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. - 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.
Known gaps
- ⚠️ Native writes are verified against
ab_serveronly (simulator-only), never a real controller. The real-press gate (Task 21) is read-only and has not run yet; this page makes no real-controller claim for the native transport. Results land in native-ab-transport-gate-results. - libplctag cannot be deleted until a bench controller allows write verification.
- Wide-struct fragmented writes (8-byte alignment) are unit-tested only, never proven on a controller (#567).
- Native browse: listing pages are unpaced, and template reads outside an enumeration open a connection per call (#572).
- Micro800, routed and
logixpcccpaths are covered byab_serveronly. The Micro800 profile passes natively; the planned default flip excludeslogixpccc. - 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 withErrorBadDatabecause the adapter sentPath=""; 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 theab_serverDocker fixture. See AbServer-Test-Fixture.md for the coverage map and theAB_SERVER_ENDPOINTwiring. - 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 memberinfofield (struct flag0x8000set). The driver captures this asAbCipUdtMember.NestedTemplateIdand threads it into a secondFetchUdtShapeAsynccall (@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; seetests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateNestedUdtTests.csfor 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 aLENDINT +DATASINT[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.Membernames no element); an array of STRING is a String array. -
Program scope —
@tagslists each program (Program:Main) but none of its tags; the enumerator reads everyProgram:<name>.@tagslisting too, so program-scoped tags appear asProgram:<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
Tagheap does not show up in CLR allocation figures. Watch whole-process RSS, and useReinitializeAsync(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(notBadCommunicationError), 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
BadTypeMismatchinstead of Good garbage. Same-width mistakes (DINT vs REAL) are undetectable under libplctag; theNativetransport reports them asBadTypeMismatch(see Native transport behaviour). - Module-defined I/O tags —
Local:1:I.Data,ABB_OmniCore:I.ConnectionFaultedand 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 leavesElementSizeunset after a read. - Declaration-only UDT grouping is a footgun unless verified — only enable
EnableDeclarationOnlyUdtGroupingwhen every UDT's member declaration order has been hand-verified against the controller's compiled layout.