Both `IDriver` members had no production call site. Their only consumer was `MemoryTracking`, deleted with the driver-tier recycle machinery in #522, and `GetMemoryFootprint`'s doc-comment claimed "polled every 30s by Core" — which had not been true since v3, even before #522 removed the poller. Owner decision on #525 was (a) delete rather than (b) build the process-wide watchdog that would consume them. The driver-side implementations are in git history and were mostly reusable; what was always missing is the trigger, and a member nobody calls is how the misleading doc-comment survived this long. Removed from `IDriver`, all 12 driver implementations, and every test stub — plus the support code that existed only to feed them, which is the part a member-only deletion would have left behind as dead weight: - MTConnect's three per-entry byte-weight constants, its cached `ProbeModelDataItemCount`, and `CountDataItems` — a full device-tree walk run on every cache fill to populate a number with no remaining reader. - OpcUaClient's `_discoveredNodeCount` field and the discovery-pass write to it. - MQTT's `ApproxBytesPerAuthoredTag`. Two behavioural notes, neither a change in what the software does: - `AbCipTemplateCache` now has no production reset path. Its only caller was AB CIP's flush, which itself had no caller — so nothing changes except how visible the fact is. Recorded on the type, along with the correction that its comment's claim about a device `ReinitializeAsync` also flushing it was never true. A UDT redefined on a live controller keeps its stale shape until the driver instance is rebuilt. - MTConnect's `/probe` cache is still dropped, by `InvalidateProbeModel()` on an Agent restart. That is now `internal` rather than `private` so the tests which used the flush purely to empty the cache drive the real path instead — they now cover a case that actually occurs in production. Several unrelated doc comments cited `FlushOptionalCachesAsync` as the canonical CAS example and are repointed to it. Tests: the ones that existed only to assert on the removed members are deleted (3 OpcUaClient, 3 Galaxy, 2 MQTT, 1 MTConnect, 1 AbCip). Worth noting one was vacuous — `FlushOptionalCachesAsync_resets_footprint_counter` asserted a footprint of 0 before the flush and 0 after with no discovery in between, so it would have passed against a flush that did nothing at all. Full suite green except 7 failures verified identical on master before and after (4 AbLegacy integration with the compose profile down, 3 Host.IntegrationTests), checked by name rather than assumed. Docs corrected where they described this as live: `DriverLifecycle.md` (which carried the #525 placeholder), `drivers/AbCip.md`, `drivers/AbLegacy.md`, and `v2/driver-stability.md` — whose "polls this every 30 s" + per-driver `DriverConfig` memory budget were never built as written and are now banner-ed as design intent rather than description. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
11 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 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.- 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.
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).Live verification is Emulate-tier only (
AB_SERVER_PROFILE=emulate+ a Logix Emulate instance):ab_server(the default Docker simulator) does not implement the CIP Template Object service (class 0x6C), so the nested-id fetch cannot be exercised against it. Seetests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Emulate/AbCipEmulateNestedUdtTests.csfor the skip-gated live-gate smoke (backlog #6). Offline unit coverage lives inAbCipDriverDiscoveryTests.Controller_discovered_UDT_nested_struct_expands_via_nested_template_id_fetch_no_seamandCipTemplateObjectDecoderTests.
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.
- 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.