Files
lmxopcua/docs/drivers/AbCip.md
T
Joseph Doherty 5018627b35 feat(abcip): #611 discovered UDT member writes + whole-UDT refusal by decision
Item 2 — controller-discovered UDT members are writable on both transports once
browse-committed and authored ReadWrite (writability is the operator's: the commit
lands AccessLevel Read with no writable key). The write names the member (one CIP
symbolic segment per member), its own type, its own bytes. BIT members are supported:
Z-35162 (read-only probe) exposes a UDT BIT member as a 1-byte 0xC1 BOOL, so the
controller applies the bit and the hidden host's other bits are never in the request.

- AbCipDriver: pre-write width check on BOTH transports (libplctag patched part of
  its read buffer and sent it all): BadTypeMismatch, Bool over a wider value
  BadNotSupported; nothing sent, handle kept, health untouched, warn once.
- A runtime NotSupportedException is a refusal: no longer latches Degraded.

Item 3 — whole-UDT writes refused BadNotSupported before any runtime exists, by
decision (a structure write overwrites hidden hosts + padding), documented.

ab_server patch 0003: serve member paths from dotted flat tags (UdtSeg.*), and
accept libplctag's pad byte on 1-byte writes (stock ab_server refused every
libplctag BOOL/SINT write with 0x03). New AbCipUdtMemberWriteTests (both
transports), AbCipDiscoveredMemberWriteTests, native request-byte theory, and a
browse-commit mapper pin. Docs: AbCip.md Member writes / Whole-UDT writes / Known
gaps, fixture docs, CLAUDE.md AB section.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 04:23:59 -04:00

37 KiB
Raw Blame History

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.Ab.LibplctagShared/ Project-less shared source (LibplctagOrphanRelease.cs, #578/#580) compiled into both Driver.AbCip and Driver.AbLegacy. The AbStatus → OPC UA table is shared in Driver.Ab.Transport (AbOpcUaStatusMap).
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 — 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.
  • Discovery — EnableControllerBrowse (@tags walk), EnableDeclarationOnlyUdtGrouping (whole-UDT read fast path) and browsePageDelayMs (native browse pacing, see Native browse).
  • 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.

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) → 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=<effective> (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. 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 §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).

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. Packing budgets the expected reply as well as the request (#582): each queued request declares an upper bound on its reply (expectedReplyBytes; reads: elements x element width, STRING = 88 bytes per element, BOOL counted as 4) and a batch is closed when the summed MSP reply (6 + 2n + sum) would exceed the connection size. A read whose size cannot be bounded (structure / unknown authored type) is sent alone; writes and status-only replies count their 4-byte header. Pinned by RequestSchedulerTests.Mixed_scalar_string_and_array_replies_are_packed_within_the_reply_budget. ⚠️ RESIDUAL: mixed STRING/array packing has not been exercised against a real controller (the real-press gate predates the budget); the budget is unit-tested against the scheduler and fake channel only.
  • Fragmented-read integrity (#567): Read Tag Fragmented replies are compared against the first reply; a changed type code or structure handle mid-read fails the read with BadReply rather than stitching two types into one value.
  • Forward Open throttle: at most one attempt in flight, at least 1 s between starts, bounded by the open budget.
  • A target-closed CIP connection is recycled at once (#566). When a connected reply says the controller no longer holds the class-3 connection while TCP stays up — general status 0x01 with extended 0x0107 (target connection not found) or 0x0203 (connection timed out), e.g. after a controller restart or a connection timeout — the session is closed (UnRegisterSession; no Forward Close, there is nothing to close) and the next request reopens through the throttle. The request that got the reply fails BadCommunicationError (transport BadConnection); a keep-alive that gets it recycles the connection before any request fails. Every other status — tag-level errors, other 0x01 extended codes — is left to the request. ⚠️ RESIDUAL: which of the two codes a real Logix controller returns for a forgotten connection (and whether it answers at all rather than dropping TCP) is unobserved; the check is correct whichever it does — a silent target still faults the session through the exchange timeout.
  • 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. The same holds when the controller refuses the existence read itself with a type error (CIP 0xFF/0x2107): initialization completes with the status instead of throwing, so the runtime is not disposed and re-created (nor, for a sole lease holder, re-Forward-Opened) on every poll, and the device's connect backoff is not opened (#570). Micro800 BYTE/WORD/LWORD (0xD1/0xD2/0xD4) are checked as integers of their width: a REAL/LREAL or a narrower integer authored over an LWORD is BadTypeMismatch, not decoded garbage (#570).
  • A malformed tag name is a per-tag configuration error (#570). A TagPath that does not parse, or a name the CIP symbolic encoder refuses (non-ASCII, an empty member such as A..B, a member longer than 255 characters, an unterminated or non-numeric subscript), reads and writes BadConfigurationError with one Warning per tag. Nothing reaches the device, so it never opens the device's connect backoff and never degrades driver health — healthy tags on the same device keep reading. (libplctag still reports its own ErrorBadParam for a name it rejects.) A malformed probe tag does not feed the backoff either.
  • STRING capacity: the built-in Logix STRING is 88 bytes on the wire — LEN + DATA[82] + 2 bytes of alignment padding — so a write holds at most 82 characters (it used to allow 84) and a read never returns the padding as characters. The write still sends the whole 88-byte structure, padding zeroed, as libplctag does. A user string type of any other size keeps the buffer-derived capacity (size − 4): the read alone cannot tell its DATA length from up to 3 bytes of padding, so a write may accept up to 3 characters more than the type holds (#570).
  • Releasing a tag never blocks (#579). Disposing a native tag runtime — the driver does it under the tag's lock on an evict, a reinitialize or a shutdown — releases its connection lease in the background (ConnectionLease.ReleaseInBackground, the same shape the native AbLegacy runtime uses). It used to wait up to 2 s for the last lease's Forward Close. Nothing is lost by not waiting: the pool records the close synchronously, so a re-create the next instant still does not open a second session until that Forward Close + UnRegisterSession have finished, and the close is bounded by the connection's own budgets (the open budget for an open still in flight, 1 s for the Forward Close), so a connection is always released. Offline soak: NativeAbCipLeaseReleaseTests (25 create/release cycles, no leaked connection); a soak against ab_server has not been run.
  • 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.
  • libplctag cannot be deleted until a bench controller allows write verification.
  • Discovered UDT member writes (#611) are likewise ab_server-only on both transports; the BIT-member argument rests on a read-only hardware observation (the member reads as a 1-byte 0xC1 BOOL). See Member writes for the candidate gate tag.
  • ⚠️ RESIDUAL (#567): wide-struct fragmented writes (8-byte alignment) are unit-tested only. Production presses are read-only and there is no bench controller, so this cannot be proven; it stays open until a bench controller exists.
  • Native browse (#572, resolved in code): listing paging no longer assumes ascending instance order, pages are paced, and a browse's template reads share one lease. One residual: whether a real controller ever lists a page out of order is unobserved — the reader is now correct either way. browsePageDelayMs is an AdminUI driver-form field ("Browse page delay (ms)", #596); keys the form does not model are preserved on save (#576).
  • 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.

Each event carries ConditionId = the subscribed reference (the alarm tag's RawPath — the key the host routes native transitions on) and Kind = Raise / Clear. Before Gitea #641 the projection sent "{ref}#active" with no kind, so the host dropped every ALMD transition as unrouted.

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 for the coverage map and the AB_SERVER_ENDPOINT wiring.
  • 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.

Native browse: paging, pacing and template leases

Native symbol browse (transport = Native; libplctag pages inside its own library and is unaffected):

  • Paging continues past the highest instance seen, not the last entry of a page. The Symbol Object listing is requested from start; the next request starts at max(instance in the reply) + 1, so the order a controller lists a page in does not matter. A "more" reply whose highest instance is still below its start (or equals uint.MaxValue) made no progress and fails with BadReply ("made no progress") rather than looping against the controller.
  • Pages are paced. browsePageDelayMs (driver config, default 5, 0 = back-to-back, negative = 0) pauses before every page after the first. 5 ms is small against a page's own round-trip, so a 50k-symbol browse gains a few seconds, but it breaks the back-to-back burst (one request in flight, roughly a Studio 5000 online browse) into slices the controller's message processing can interleave with. Raise it for a controller that is tight on CPU; the pause applies per listing (the controller listing and each program listing).
  • Template reads in a browse share one lease. DiscoverAsync reads the UDT templates (and nested templates) of a device through a single batch reader (IAbCipTemplateReaderFactory.CreateBatch); the native batch reader holds one device lease from its first read until the browse of that device ends, instead of leasing per template. FetchUdtShapeAsync outside a discovery run is unchanged: one self-contained lease per call.

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:<name>.@tags listing too, so program-scoped tags appear as Program:<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

Discovered UDT members are writable, on both transports (Gitea #611). There is no separate "discovered member" write path to enable — under v3 a browsed member leaf becomes an ordinary raw tag when it is browse-committed in /raw, and that tag writes like any other atomic tag:

  • Writability is the operator's, never the browse's. Browse-commit (RawBrowseCommitMapper) writes tagPath = the member path (Seg.RampTime, nested Seg.Sub.Code, program-scoped Program:Main.Seg.ID) and dataType = the template's atomic type, at AccessLevel Read and with no writable key. The node is writable only once the operator sets the raw tag to ReadWrite (the node-manager gate); an authored "writable": false still refuses at the driver (BadNotWritable). The browse's SecurityClass (members inherit the parent symbol's read-only bit) is not consulted by the commit. An element of a member array is authored by hand as Seg.Points[2]; a whole member array is not writable (AbCip has no array encoder, DriverArrayWriteSupport).
  • The request names the member. One CIP symbolic segment per member (plus an element segment), the member's own CIP type, the member's own bytes — never the parent UDT, never a hidden host. Pinned at the request-byte level by NativeAbCipTagRuntimeTests.A_member_path_write_names_the_member_and_sends_only_its_own_bytes and end to end on ab_server by AbCipUdtMemberWriteTests (both transports, value read back through a fresh driver).
  • BIT members (a UDT BIT, which Logix packs into a hidden ZZZZZZZZZZ… SINT host) are supported. A controller exposes the member as a standalone BOOL: on Z-35162, …_LastSegment.Enable reads type 0xC1, one byte, while the hidden host at the same offset holds that bit (read-only probe, 2026-10-09). So a BOOL write to Seg.Enable is a symbolic write of one BOOL byte to the member, and the controller — not the driver — applies the bit; the host's other bits are never in the request. No driver-side read-modify-write is involved (that is reserved for Tag.N bit selectors on an integer, below), and the hidden host itself is never browsed, so it cannot be authored by browse-commit.
  • Type checking, before anything is sent, on both transports. The driver compares the authored type with the bytes the controller holds at the path (the runtime's last read) and refuses a mismatch without writing: a narrower/wider atomic type is BadTypeMismatch (libplctag would otherwise patch part of its read buffer and send the whole buffer back — e.g. an Int over a DINT member wrote four bytes with a stale high word); a Bool over a wider value (a packed BOOL-array DWORD, a mistyped integer) is BadNotSupported, because writing it would rewrite every neighbouring bit. Native additionally refuses a same-width mismatch (REAL vs DINT) from the CIP type code — see Native transport behaviour. A refusal keeps the handle, warns once per tag and does not touch driver health.
  • Not verified on a real controller. Production presses are read-only, so member writes are proven on ab_server only (Docker patch 0003 serves member paths). A candidate for a future approved write gate: Z-35162 controller tag _EmptyTemperatureSegment (UDT_TemperatureProfile_Segment), which no logic references — its members (ID, Enable, RampTime, …) are all zero and resolve as described above.

Pre-declared Members with Writable: true keep working exactly as before (same write path).

Whole-UDT writes — refused, by decision

A write to a UDT container tag (dataType: Structure) is refused BadNotSupported before any runtime is created or anything is sent, with no health change and one Warning per tag. This is a decision, not a deferral (#611): a whole-structure write overwrites every member of the controller's instance — hidden BOOL hosts and alignment padding included — with whatever bytes the client supplied, and the member writes above cover the use case without that blast radius. A runtime-level NotSupportedException (the libplctag and native encoders both refuse Structure) is likewise a refusal: BadNotSupported, handle kept, health untouched (it used to latch Degraded).

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). The width-mismatch refusal is pinned by AbCipTypeMismatchTests.A_width_mismatched_read_keeps_the_handle_leaves_health_alone_and_warns_once: BadTypeMismatch, handle kept, no Degraded, one Warning per tag. The same width check runs before every scalar write, on both transports (#611): a mismatch is refused BadTypeMismatch (a Bool over a wider value BadNotSupported) with nothing sent — see Member writes. Benign edge (#585): a driver left Degraded by an earlier outage whose only remaining tags are mis-typed gets one extra comms-loss-watchdog reconnect ~90 s later before settling, because a type refusal deliberately writes no health and so never clears the stale Degraded.
  • 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.