9c39f2b6c4
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
391 lines
32 KiB
Markdown
391 lines
32 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 alarm projection. |
|
||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared/` | Project-less shared source (`LibplctagOrphanRelease.cs`, #578/#580) compiled into both `Driver.AbCip` and `Driver.AbLegacy`. The `AbStatus` → OPC UA table is shared in `Driver.Ab.Transport` (`AbOpcUaStatusMap`). |
|
||
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/` | `AbCipDriverOptions`, `AbCipDeviceOptions`, `AbCipTagDefinition` / `AbCipStructureMember`, and the `AbCipDataType` / `AbCipPlcFamily` enums bound from the driver's `DriverConfig` JSON. |
|
||
|
||
Per family the `AbCipPlcFamilyProfile` (`PlcFamilies/AbCipPlcFamilyProfile.cs`)
|
||
supplies the libplctag `plc` attribute, default CIP path, ConnectionSize, and
|
||
request-packing / connected-messaging quirks — ControlLogix is the baseline and
|
||
each other family is a delta (Micro800 is unconnected-only with no backplane
|
||
routing; GuardLogix shares the ControlLogix wire protocol with a tag-level safety
|
||
partition).
|
||
|
||
## Capability Surface
|
||
|
||
`AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable`
|
||
(`Driver.AbCip/AbCipDriver.cs`). It adds **`IAlarmSource`** over the Modbus /
|
||
AB Legacy surface.
|
||
|
||
| Capability | Implementation entry point | Notes |
|
||
|------------|---------------------------|-------|
|
||
| `ITagDiscovery` | `DiscoverAsync` | Emits pre-declared tags under per-device folders; UDT tags with declared `Members` fan out into a sub-folder + one variable per member. With `EnableControllerBrowse` the `@tags` symbol table — plus each `Program:<name>.@tags` listing — is walked into a `Discovered/` folder (system/module/routine tags filtered out). |
|
||
| `IReadable` | `ReadAsync` → `ReadGroupAsync` / `ReadSingleAsync` | Per-tag reads; opt-in whole-UDT grouping (`EnableDeclarationOnlyUdtGrouping`) collapses N member reads into one. |
|
||
| `IWritable` | `WriteAsync` | BOOL-within-DINT writes do a per-parent read-modify-write under a lock; `SafetyTag` and non-writable tags return `BadNotWritable`. |
|
||
| `ISubscribable` | `SubscribeAsync` driven by the shared `PollGroupEngine` | CIP has no push model — subscriptions become polling groups. |
|
||
| `IHostConnectivityProbe` | `ProbeLoopAsync` + `GetHostStatuses` | One probe loop per device reading `Probe.ProbeTagPath`; no path configured ⇒ a warning is logged and the device stays `Unknown`. |
|
||
| `IPerCallHostResolver` | `ResolveHost` | Routes each call to the tag's `DeviceHostAddress`, the breaker key for the resilience pipeline so one dead PLC trips only its own breaker. |
|
||
| `IAlarmSource` | `AbCipAlarmProjection` (ALMD) | Opt-in via `EnableAlarmProjection`; off by default the subscribe path is a no-op so capability negotiation still works. |
|
||
|
||
## Addressing Model
|
||
|
||
Per-device host addresses are the canonical `ab://gateway[:port]/cip-path` form
|
||
parsed by `AbCipHostAddress.TryParse` (`AbCipHostAddress.cs`). The parsed
|
||
`CipPath` is handed to libplctag verbatim, so no wire-layer translation is
|
||
needed:
|
||
|
||
| Form | Meaning |
|
||
|------|---------|
|
||
| `ab://10.0.0.5/1,0` | Single-chassis ControlLogix, CPU in slot 0 |
|
||
| `ab://10.0.0.5/1,2,2,192.168.50.20,1,0` | Bridged ControlLogix (routed path) |
|
||
| `ab://10.0.0.5/` | Micro800 / no-backplane device (empty path) |
|
||
| `ab://10.0.0.5:44818/1,0` | Explicit EIP port (default 44818) |
|
||
|
||
Tags carry a Logix symbolic `TagPath` (controller or program scope). UDT-typed
|
||
tags are declared as `AbCipDataType.Structure` with a `Members` list; discovery
|
||
fans each member out as `{tag.Name}.{member.Name}`, and the read planner can
|
||
collapse a batch of members into one whole-UDT read when
|
||
`EnableDeclarationOnlyUdtGrouping` is set. The whole-UDT fast path is opt-in
|
||
because Studio 5000 may reorder members vs declaration order; decoding at
|
||
declaration-order offsets against a reordered layout yields silently-plausible
|
||
wrong numbers.
|
||
|
||
## Configuration
|
||
|
||
`AbCipDriverOptions` (`Driver.AbCip.Contracts/AbCipDriverOptions.cs`) binds from
|
||
the driver's `DriverConfig` JSON. Key fields:
|
||
|
||
- **`Devices`** — one `AbCipDeviceOptions` per PLC (`HostAddress`, `PlcFamily`, optional `DeviceName`, per-device `AllowPacking` / `ConnectionSize` overrides).
|
||
- **`Tags`** — pre-declared `AbCipTagDefinition` list; `Members` for UDT fan-out, `SafetyTag` for GuardLogix safety-partition tags.
|
||
- **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeTagPath`. A driver created from the AdminUI starts with the probe **off** (an enabled probe with no tag path only logs a warning); an absent `probe` key in a stored config still means enabled to the driver.
|
||
- AdminUI driver forms (AbCip/AbLegacy) overlay only the keys they edit onto the stored JSON, so unmodelled keys survive a save (#576). They also write durations as the integer-millisecond keys the factory binds (`timeoutMs`, `alarmPollIntervalMs`, `probe.intervalMs`, `probe.timeoutMs`); a legacy TimeSpan-string key (`timeout`: "00:00:05") is loaded and migrated on save.
|
||
- **`Transport`** — absent (default ⇒ `Native`), `Native`, or `Libplctag` (fallback); see [Transport](#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 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`.
|
||
⚠️ RESIDUAL: mixed STRING/array packing has not been exercised against a real controller (the real-press
|
||
gate predates the budget); the budget is unit-tested against the scheduler and fake channel only.
|
||
- **Fragmented-read integrity (#567):** `Read Tag Fragmented` replies are compared against the first reply;
|
||
a changed type code *or* structure handle mid-read fails the read with `BadReply` rather than stitching
|
||
two types into one value.
|
||
- **Forward Open throttle:** at most one attempt in flight, at least 1 s between starts, bounded by the
|
||
open budget.
|
||
- **A target-closed CIP connection is recycled at once (#566).** When a connected reply says the controller
|
||
no longer holds the class-3 connection while TCP stays up — general status `0x01` with extended `0x0107`
|
||
(target connection not found) or `0x0203` (connection timed out), e.g. after a controller restart or a
|
||
connection timeout — the session is closed (UnRegisterSession; no Forward Close, there is nothing to close)
|
||
and the next request reopens through the throttle. The request that got the reply fails
|
||
`BadCommunicationError` (transport `BadConnection`); a keep-alive that gets it recycles the connection
|
||
before any request fails. Every other status — tag-level errors, other `0x01` extended codes — is left to
|
||
the request. ⚠️ RESIDUAL: which of the two codes a real Logix controller returns for a forgotten
|
||
connection (and whether it answers at all rather than dropping TCP) is unobserved; the check is correct
|
||
whichever it does — a silent target still faults the session through the exchange timeout.
|
||
- `ConnectionSize` / `Timeout` / `AllowPacking` are honoured per pooled connection, and the **first**
|
||
driver/device to open a given device key wins — including a Test Connect probe on a fused `admin,driver`
|
||
node (its connection carries the probe timeout). A later opener with different options is ignored and
|
||
logged once as a Warning (`DeviceSessionPool`). ⚠️ The device key compares the **host string**, so the
|
||
same controller addressed by DNS name in one driver and by IP in another gets two connections and two
|
||
Forward Open throttles (up to two opens per second); address a controller the same way everywhere. ControlLogix, GuardLogix and (since T21) CompactLogix default to 4002, a Large
|
||
Forward Open (0x5B), the same as libplctag. A target that refuses Large gets one regular open at
|
||
min(size, 504) inside the same throttled attempt. An explicit `ConnectionSize` in the options or on the
|
||
device overrides the family default.
|
||
- **Routing:** Micro800 is connected with an empty route regardless of `UseConnectedMessaging`.
|
||
SLC500/MicroLogix/PLC-5 over this driver are direct. `logixpccc` uses UnconnectedSend; its SLC framing is
|
||
**unverified**.
|
||
- ⚠️ **Type check (client-visible change).** The native path compares the type code the controller returns
|
||
with the authored `DataType` and reports **`BadTypeMismatch`** on disagreement. libplctag never checks it,
|
||
so a same-width mis-authored tag read as **Good garbage**: authoring a tag as `DINT` while the controller
|
||
holds a `REAL` returns the REAL's bits as an integer with a Good status under libplctag, and
|
||
`BadTypeMismatch` natively. That is the fix working, but a consumer will see Bad quality on tags that
|
||
previously looked healthy: correct the authored type. Same-width signed/unsigned pairs are exempt, and
|
||
writes are gated on a mismatch too. A mismatch is a **deterministic configuration refusal**, handled like
|
||
an Unsupported refusal (final review I4): the tag's handle is kept (no re-create, no reconnect), driver
|
||
health is unchanged (no `Degraded`, nothing fed to the comms-loss watchdog), and the Warning is logged
|
||
**once per tag** per driver instance, not every poll. The same applies to the libplctag-path width check.
|
||
The same holds when the controller refuses the **existence read itself** with a type error (CIP
|
||
`0xFF`/`0x2107`): initialization completes with the status instead of throwing, so the runtime is not
|
||
disposed and re-created (nor, for a sole lease holder, re-Forward-Opened) on every poll, and the device's
|
||
connect backoff is not opened (#570). Micro800 `BYTE`/`WORD`/`LWORD` (`0xD1`/`0xD2`/`0xD4`) are checked as
|
||
integers of their width: a `REAL`/`LREAL` or a narrower integer authored over an `LWORD` is
|
||
`BadTypeMismatch`, not decoded garbage (#570).
|
||
- **A malformed tag name is a per-tag configuration error** (#570). A `TagPath` that does not parse, or a
|
||
name the CIP symbolic encoder refuses (non-ASCII, an empty member such as `A..B`, a member longer than
|
||
255 characters, an unterminated or non-numeric subscript), reads and writes **`BadConfigurationError`**
|
||
with one Warning per tag. Nothing reaches the device, so it never opens the device's connect backoff and
|
||
never degrades driver health — healthy tags on the same device keep reading. (libplctag still reports its
|
||
own `ErrorBadParam` for a name it rejects.) A malformed **probe** tag does not feed the backoff either.
|
||
- **STRING capacity:** the built-in Logix `STRING` is 88 bytes on the wire — `LEN` + `DATA[82]` + 2 bytes of
|
||
alignment padding — so a write holds at most **82** characters (it used to allow 84) and a read never
|
||
returns the padding as characters. The write still sends the whole 88-byte structure, padding zeroed, as
|
||
libplctag does. A user string type of any other size keeps the buffer-derived capacity (`size − 4`): the
|
||
read alone cannot tell its `DATA` length from up to 3 bytes of padding, so a write may accept up to 3
|
||
characters more than the type holds (#570).
|
||
- **Releasing a tag never blocks** (#579). Disposing a native tag runtime — the driver does it under the
|
||
tag's lock on an evict, a reinitialize or a shutdown — releases its connection lease in the background
|
||
(`ConnectionLease.ReleaseInBackground`, the same shape the native AbLegacy runtime uses). It used to wait
|
||
up to 2 s for the last lease's Forward Close. Nothing is lost by not waiting: the pool records the close
|
||
synchronously, so a re-create the next instant still does not open a second session until that Forward
|
||
Close + UnRegisterSession have finished, and the close is bounded by the connection's own budgets (the
|
||
open budget for an open still in flight, 1 s for the Forward Close), so a connection is always released.
|
||
Offline soak: `NativeAbCipLeaseReleaseTests` (25 create/release cycles, no leaked connection); a soak
|
||
against `ab_server` has not been run.
|
||
- **A refused operation is not a comms fault.** Any operation a runtime refuses as Unsupported (in AbCip, e.g.
|
||
a native write whose type cannot be determined) reaches the client as `BadNotSupported`, keeps the tag
|
||
handle, and leaves driver health unchanged; only real transport failures evict and degrade.
|
||
|
||
## Known gaps
|
||
|
||
- ⚠️ **Native writes are verified against `ab_server` only (simulator-only), never a real controller.** The
|
||
real-press gate (Task 21) was **read-only**: it proved native reads, browse and templates on Z-35162
|
||
(2026-10-08) and nothing else. Results:
|
||
[native-ab-transport-gate-results](../plans/2026-10-07-native-ab-transport-gate-results.md).
|
||
- **libplctag cannot be deleted** until a bench controller allows write verification.
|
||
- ⚠️ RESIDUAL (#567): wide-struct **fragmented writes** (8-byte alignment) are unit-tested only. Production
|
||
presses are read-only and there is no bench controller, so this cannot be proven; it stays open until a
|
||
bench controller exists.
|
||
- Native browse (#572, resolved in code): listing paging no longer assumes ascending instance order, pages
|
||
are paced, and a browse's template reads share one lease. One residual: whether a real controller ever
|
||
lists a page out of order is unobserved — the reader is now correct either way. `browsePageDelayMs` is an AdminUI driver-form field ("Browse page delay (ms)", #596); keys the form does not model are preserved on save (#576).
|
||
- Micro800, routed and `logixpccc` paths are covered by `ab_server` only. The Micro800 profile passes
|
||
natively. The AbLegacy default excludes `logixpccc` (and PLC-5).
|
||
- Follow-up issues for this workstream: #564 to #577.
|
||
- Fixed along the way (pre-existing on master): `ab://host:44818/` with an empty CIP path (Micro800,
|
||
unrouted MicroLogix) failed on libplctag with `ErrorBadData` because the adapter sent `Path=""`; it now
|
||
sends no path.
|
||
|
||
## Alarm Projection
|
||
|
||
`IAlarmSource` is served by `AbCipAlarmProjection`, which polls each subscribed
|
||
ALMD UDT's `InFaulted` + `Severity` members at `AlarmPollInterval` and fires
|
||
`OnAlarmEvent` on raise/clear transitions. It is **ALMD-only** in this pass (ALMA
|
||
analog alarms are a follow-up) and **disabled by default** — shops running FT
|
||
Alarm & Events should keep it off and take alarms through the native route, since
|
||
the projection semantics don't exactly mirror Rockwell FT A&E.
|
||
|
||
## 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
|
||
|
||
UDT member **writes** discovered via controller browse are **deferred** in this release.
|
||
Pre-declared `Members` with `Writable: true` retain full read/write support as before.
|
||
|
||
### Configuration
|
||
|
||
No new configuration keys are required. Set `EnableControllerBrowse: true` in the
|
||
`AbCipDriverOptions` JSON to enable the `@tags` walk; UDT member expansion is automatic for all
|
||
UDT-typed tags found in the walk.
|
||
|
||
## Operational Notes
|
||
|
||
- **Native heap is invisible to the GC.** libplctag's native `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. *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.
|