a7c27e978f
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
516 lines
44 KiB
Markdown
516 lines
44 KiB
Markdown
# 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.
|