# 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`. - **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). ## 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 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.