Native is the default, so a locally refused write must not look like a comms fault. - AbLegacy: an AbTransportException with AbStatus.Unsupported (status-bit sub-element writes, PLC-5 non-N reads, all PLC-5 writes; libplctag's ErrorUnsupported maps here too) now returns BadNotSupported on both the read and the write path (incl. the bit-RMW path). The tag handle is not evicted and driver health is not touched. A native runtime whose init read is refused locally is CACHED rather than disposed, so a poll of a refused tag does not re-create (and re-lease the pooled connection) every cycle. - AbCip: the same mapping on the write path and both read paths (single + whole-UDT). - Uses the existing named BadNotSupported constants (StatusCodeParityTests guard). - Tests (falsified by disabling the mapping, and separately the keep-on-refusal): status-bit writes and PLC-5 read/write through the driver over the native runtime — BadNotSupported, zero wire requests, one create, health unchanged; AbCip refused read/write via a refusing fake plus a control that a non-refusal failure still evicts and degrades. - Docs/CLAUDE.md: BadNotSupported; #571 known-gap wording removed. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
21 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— absent (default ⇒Native),Native, orLibplctag(fallback); 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 |
absent | Native | Libplctag |
absent ⇒ 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. - 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
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 ("Default (Native)" / "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 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(defaultdefault, which goes through the same resolver). - libplctag is confined to
Libplctag*.csadapters, 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
transportkey runsNative; libplctag remains available as the fallback. - ⚠️ Client-visible: native reports
BadTypeMismatchfor a mis-authored same-width type (for example a tag authoredDINTwhere the controller holds aREAL), where libplctag returned Good garbage. Correct the authored type; see Native transport behaviour. - To fall back, set
"transport": "Libplctag"in the driver config (or pick "libplctag (fallback)" in the AdminUI dropdown). - ⚠️ 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
transportkey 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 onReinitializeAsync). - 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 §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
logixpcccdevice stays on libplctag (see AbLegacy.md §Transport).
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. - 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_serveronly (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. - 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 AbLegacy default excludeslogixpccc(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 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.