# 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/ALMA 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:.@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 + ALMA) | 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), `EnableDeclarationOnlyUdtGrouping` (whole-UDT read fast path) and `browsePageDelayMs` (native browse pacing, see [Native browse](#native-browse-paging-pacing-and-template-leases)). - **Alarms** — `EnableAlarmProjection` + `AlarmPollInterval` for the ALMD/ALMA 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. **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`. Exercised on a real CompactLogix (Z-35162, hw4, 2026-10-09): a STRING, an array element, a DINT[32] and two scalars rode one Multiple Service Packet in 5/5 attempts, values equal to single reads. - **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. **Observed on a real CompactLogix (Z-35162, hw5, 2026-10-09): neither code.** A connection left idle 60 s, past its 16 s timeout (keep-alive disabled for the test), was closed at the TCP level somewhere in that 16–60 s window — the exact moment, and whether the controller sent RST or FIN, were not captured (no packet capture; EPIPE on the first write only says the close was complete by then); the next request failed on its write in 9 ms (`Broken pipe` → `BadConnection` → `BadCommunicationError`, no CIP or encapsulation status) and the request after it reopened with a fresh Forward Open. That path needs no CIP check — a faulted session always reopens on the next send — and is pinned offline by `CipConnectionTests.A_target_that_resets_an_idle_session_fails_the_next_request_fast_and_the_one_after_reopens`. The 0x0107/0x0203 recycle stays for targets that keep TCP up. In production the keep-alive (every 8 s) stops a held connection idling out, so this shape follows a controller restart / re-download or a network gap. - `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 hardware evidence is narrow.** The read-only real-press gate (Task 21, 2026-10-08) proved native reads, browse and templates on Z-35162; a user-approved one-off on 2026-10-09 added **scalar native writes on a real CompactLogix** (DINT `test2`, BOOL `Program:UN01_Press.xxtest`, REAL `Program:ES03_EM03_Zone3._ff_test`: write, read back, restore, all Good), and the #611 gate added **native UDT member writes** (DINT, BIT and REAL members of `_EmptyTemperatureSegment`). hw5 (2026-10-09) added a **native STRING write** (`Program:ES03_TemperatureControl._StrTmp`, through the driver: write, read back, restored byte-exact). A user-approved **native array-element write** followed: `Program:EM01_PowerUnit._SEQ_EP[31]` 0→4242→0 (the array's only alias targets `[1]`, no routine indexes or block-copies it; all 32 elements snapshotted twice before, after the write and after the restore — only `[31]` changed). Still ab_server-only: whole-array writes (the driver has none), libplctag member writes, and fragmented writes. #566 is answered (the controller drops TCP; see "A target-closed CIP connection"). Mixed STRING/array/scalar Multiple Service Packet reads (#582) are proven on the press (one 5-read MSP, values equal to single reads). Results: [native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md). - **libplctag cannot be deleted yet.** Scalar, UDT-member and STRING native writes are proven on a CompactLogix (2026-10-09); what remains is #567 (fragmented wide-struct writes), whole-array writes on hardware, and AbLegacy native beyond the MicroLogix 1400 evidence. - UDT **member writes** (#611) are proven on a real CompactLogix on the **native** transport only (2026-10-09); the libplctag member-write path is `ab_server`-only. See [Member writes](#member-writes). - ⚠️ RESIDUAL (#567): wide-struct **fragmented writes** (8-byte alignment) are unit-tested only. A write fragments only above `MaxRequestBytes` (4000 B on a Large Forward Open), so it needs a ~4 KB value; Z-35162 has no safe one (a DINT[32] or a STRING is a single Write Tag, and AbCip has no whole-array write anyway). It stays open until a bench controller or an approved large scratch tag 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. Observed on Z-35162 (2026-10-09, 41 pages / 3162 tags): every page strictly ascending, so out-of-order listing has never been seen on a real controller — the reader is 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 `InAlarm` + `Severity` members (or an ALMA's limit members, below) at `AlarmPollInterval` and fires `OnAlarmEvent` on raise/clear transitions. It handles ALMD (digital) and ALMA (analog, `ALARM_ANALOG`, Gitea #611) and is **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. **ALMD's active bit is `InAlarm`** (Rockwell's ALMD reference). Until hw4 the projection polled `InFaulted`, which is the *input-health* flag (Status.1, set when the program raises `InFault`), so a live ALMD would have raised on input faults and never on the alarm itself. Declare `InAlarm` (and `Severity`) on an ALMD tag. **Migration:** a tag that declared `InFaulted` but not `InAlarm` now never raises (the `InAlarm` read is `BadNodeIdUnknown`); the projection logs a once-per-tag Warning at subscribe — add `InAlarm` to the tag's members. ### ALMA (analog alarms) A subscribed ref is ALMA when its declared tag definition (`Structure` with `members`) carries a per-limit status bit — any of `HHInAlarm` / `HInAlarm` / `LInAlarm` / `LLInAlarm` / `ROCPosInAlarm` / `ROCNegInAlarm` — **and no plain `Acked`** (an ALMD member; `AbCipAlarmDetector.IsAlma`). Anything else stays ALMD, so an ALMD-shaped user UDT is not misclassified. ⚠️ **An ALMA authored without `members` cannot be told apart from an ALMD and is projected as an ALMD** (it would poll `InAlarm`/`Severity`, which an ALMA lacks, and never raise) — declare the members. **Declare the members you want projected**: the projection reads only the limits declared (their `*InAlarm`, and where declared their `*Acked` and `*Severity`). Names/types are from Rockwell's Studio 5000 "Analog Alarm (ALMA)" reference (BOOL status bits, DINT severities 1..1000). ⚠️ With `EnableDeclarationOnlyUdtGrouping` the whole-UDT decode computes offsets from the DECLARATION order, which for a partially declared ALMA is not the controller's layout — leave grouping off for ALMA tags (BOOL members are read member by member anyway). - **One condition per ALMA tag**, `ConditionId` = the RawPath, `AlarmType` = `ALMA`. Active = any polled limit bit set. Severity (exact, `OpcUaSeverity`, plus the 4-bucket enum) and message (`ALMA HH alarm`, `H`, `L`, `LL`, `ROC+`, `ROC-`) come from the **most severe active limit** (ties: HH, LL, H, L, ROC+, ROC-). - **What the host is told** (last-told state — active-limit mask, acked, governing limit + severity — is driver-owned, so a re-subscribe never re-raises a still-active alarm): none→active `Raise`, or the atomic `ActiveAcknowledged` when every declared `*Acked` is true (always so when the instruction's `AckRequired` is false); active→none `Clear`; a limit **newly** entering while active `Retrigger` (a new unacknowledged occurrence) — or `ActiveAcknowledged` if nothing is pending ack, never Retrigger-then-Acknowledge; every declared `*Acked` bit going true while the condition was unacknowledged `Acknowledge`; a Good `*Acked` read going false on a fully-acked condition with the same limits active `Retrigger` (an occurrence the poll missed); a limit dropping out (the governing limit or its severity changes, no new occurrence) an `Unspecified` event flagged `IsRestatement`, which `NativeAlarmProjector` applies as a text/severity move with **no `/alerts` row**. A non-governing limit flickering emits nothing. - **"Acked" is judged over every declared limit, active or not.** In Logix an occurrence that returned to normal unacknowledged keeps its `*Acked` bit false, so it keeps the condition unacknowledged — a surviving, already-acked limit never turns it into a device "Acknowledged" for an excursion nobody acknowledged. - **Bad reads:** a Bad `*InAlarm` or severity read skips that tick (no false Clear, no text change); a Bad `*Acked` read carries the last-told ack state forward instead of recording "unacknowledged". - **Acknowledge** writes the ALMA's `OperAckAll` input (not `Acked`, which ALMA lacks); declare `OperAckAll` as a writable member. A failed acknowledge write is now logged at Warning (it used to vanish silently). The `*Acked` bits are instruction outputs and are never written. - **Not projected:** `Disabled`, `Suppressed` and `InFaulted` (input-health) — the event contract has no enable/suppress channel — and the aggregate `AnyInAlarmUnack` / per-limit `*InAlarmUnack` bits (derivable from the polled `*InAlarm` + `*Acked`). - **Evidence:** unit tests over fake runtimes (`AbCipAlmaProjectionTests`, BOOL members, member-by-member reads, plus a mixed ALMD+ALMA subscription) and a host-level test, `Runtime.Tests/AbCipAlmaNativeAlarmEndToEndTests`, that walks raise → escalate → drop-out → ack → unacked excursion → clear through `DriverHostActor` and asserts the Part 9 updates and `/alerts` rows. `ab_server` has no alarm instructions and the Z35162 press (CompactLogix 5069-L320ERS2) has no ALMA/ALMD, so there is **no wire-level or live gate** for ALMA or ALMD; it needs a controller that actually runs the instruction (Logix Emulate tier). ## 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. ## 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:.@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 **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 is never written, because writing it would rewrite every neighbouring bit. On **native** the runtime judges it from the CIP type, exactly as its read does: a packed BOOL-array DWORD (`0xD3`) is `BadNotSupported`, a BOOL authored over a mistyped integer member is `BadTypeMismatch`. **libplctag** exposes only the size, cannot tell the two apart, and refuses both `BadNotSupported`. Native additionally refuses a same-width mismatch (REAL vs DINT) from the CIP type code — see [Native transport behaviour](#native-transport-behaviour). A refusal keeps the handle, warns once per tag and **does not touch driver health**. - **Live gate (2026-10-09, user-approved, native transport, restored):** on Z-35162, controller tag `_EmptyTemperatureSegment` (`UDT_TemperatureProfile_Segment`, referenced by no logic) — `ID` (DINT) 0→4242→0, `Enable` (BIT member) false→true→false, `HoldTime` (REAL) 0→42.5→0; every write and read Good, and a full snapshot of all ten members after each write and restore showed only the written member changed. The BIT member is the only BIT in that UDT, so preservation of *sibling bits in the same host SINT* is still `ab_server`-proven only. The libplctag member-write path was not exercised on hardware (ab_server only, Docker patch 0003). Pre-declared `Members` with `Writable: true` keep working as before — same write path, now width-checked (a mistyped declared member is refused where libplctag used to write it). ### 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](#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](#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.