Files
lmxopcua/docs/drivers/AbCip.md
T

516 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:<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 + 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=<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](#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 <ref> 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:<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 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.