# 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:.@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`. A driver created from the AdminUI starts with the probe **off** (an enabled probe with no tag path only logs a warning); an absent `probe` key in a stored config still means enabled to the driver. - AdminUI driver forms (AbCip/AbLegacy) overlay only the keys they edit onto the stored JSON, so unmodelled keys survive a save (#576). They also write durations as the integer-millisecond keys the factory binds (`timeoutMs`, `alarmPollIntervalMs`, `probe.intervalMs`, `probe.timeoutMs`); a legacy TimeSpan-string key (`timeout`: "00:00:05") is loaded and migrated on save. - **`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= (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. - **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. - 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 `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:.@tags` listing too, so program-scoped tags appear as `Program:.`. 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/` 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.