# AB Legacy Driver In-process native-protocol driver that exposes legacy Allen-Bradley PLCs — **SLC 500**, **MicroLogix**, **PLC-5**, and Logix-via-PCCC — as OPC UA nodes. It runs inside the OtOpcUa server's .NET 10 AnyCPU process and speaks PCCC over EtherNet/IP through the same libplctag.NET wrapper as the AB CIP driver, but addresses data by **file** (data-table) rather than by symbolic tag. One driver instance can serve many devices; per-device routing is keyed on the canonical `ab://gateway[:port]/cip-path` host-address string. PCCC has no native push model, so subscriptions are a polling overlay on top of `IReadable`. For the driver spec (capability surface, config shape, payload limits), see [docs/v2/driver-specs.md §4](../v2/driver-specs.md). For the manual test client, see [Driver.AbLegacy.Cli.md](../Driver.AbLegacy.Cli.md). For the integration fixture coverage map, see [AbLegacy-Test-Fixture.md](AbLegacy-Test-Fixture.md). ## Project Layout | Project | Role | |---------|------| | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/` | The driver — `AbLegacyDriver`, the libplctag runtime wrapper, the PCCC file-address parser (`AbLegacyAddress`), the host-address parser, and the status mapper. | | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/` | `AbLegacyDriverOptions`, `AbLegacyDeviceOptions`, `AbLegacyTagDefinition`, and the `AbLegacyDataType` / `AbLegacyPlcFamily` / `AbLegacyPlcFamilyProfile` records bound from the driver's `DriverConfig` JSON. | Per family the `AbLegacyPlcFamilyProfile` supplies the libplctag `plc` attribute, default CIP path, max-payload bytes, and the `SupportsStringFile` / `SupportsLongFile` capability flags. MicroLogix uses direct EIP (empty default path); MicroLogix and PLC-5 don't ship L-files; PLC-5 predates them entirely. Tag types are validated against the device's profile at init time — declaring a `Long` or `String` tag on a family that can't support it fails fast with a clear message. ## Capability Surface `AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, IDisposable, IAsyncDisposable` (`Driver.AbLegacy/AbLegacyDriver.cs`). There is **no `IAlarmSource`** — unlike the AB CIP driver, PCCC has no ALMD instruction to project, so alarms are out of scope. | Capability | Implementation entry point | Notes | |------------|---------------------------|-------| | `ITagDiscovery` | `DiscoverAsync` | Emits pre-declared tags under per-device folders. Tags are single-element today (`IsArray` hard-wired false); multi-element file ranges are a tracked follow-up. | | `IReadable` | `ReadAsync` | Per-tag reads serialized per cached runtime under a lock (a libplctag `Tag` handle is not concurrency-safe across the server read path + poll loop). | | `IWritable` | `WriteAsync` | Bit-within-word writes (N/L-file `N7:0/3`, B-file `B3:0/0`, and I/O image files `I:0/0`/`O:1/2`) do a per-parent-word read-modify-write under a lock. An Input-image (`I`) write is forwarded to the PLC and may be rejected by the device (the device drives the input image from physical inputs); the driver surfaces the device's real PCCC status in that case. Non-writable tags return `BadNotWritable`. | | `ISubscribable` | `SubscribeAsync` driven by the shared `PollGroupEngine` | No push model — subscriptions become polling groups. | | `IHostConnectivityProbe` | `ProbeLoopAsync` + `GetHostStatuses` | One probe loop per device reading `Probe.ProbeAddress`; transitions log Warning (down) / Information (recover). | | `IPerCallHostResolver` | `ResolveHost` | Routes each call to the tag's `DeviceHostAddress`; unknown references fall back to the first device, never throwing (per the interface contract). | ## Addressing Model Per-device host addresses are the canonical `ab://gateway[:port]/cip-path` form parsed by `AbLegacyHostAddress.TryParse`. When the parsed CIP path is empty the family profile's default path is used (e.g. SLC 500 gets `1,0`; MicroLogix stays empty for direct EIP). Tags carry a PCCC **file address** parsed by `AbLegacyAddress` (`AbLegacyAddress.cs`) — file letter + file number + word number, with an optional bit index (`/N`) or structured sub-element (`.ACC`, `.PRE`, …). The string is passed straight through to libplctag's `name=` attribute; the parser validates shape and surfaces the pieces for driver-side routing (e.g. deciding a bit needs read-modify-write): | Form | Meaning | |------|---------| | `N7:0` | Integer file 7, word 0 (signed 16-bit) | | `F8:0` | Float file 8, word 0 (32-bit IEEE-754) | | `B3:0/0` | Bit file 3, word 0, bit 0 | | `L9:0` | Long-integer file (SLC 5/05+, 32-bit) | | `ST9:0` | String file (82-byte fixed-length) | | `T4:0.ACC` / `C5:0.PRE` | Timer / counter sub-element | | `I:0/0` / `O:1/2` / `S:1` | Input / output / status system files (no file number) | | `I:017/07` | **PLC-5 only:** `I:`/`O:` word and bit indices are **octal** (#244); a digit 8/9 is a parse error | | `RTC:0.HR` / `HSC:0.ACC` | **MicroLogix only:** function files RTC, HSC, DLS, MMI, PTO, PWM, STI, EII, IOS, BHI (#245) | | `N7:[N7:0]` / `N[N7:0]:5` | Indirect (index word read from the PLC) — resolved at read/write time (#646) | `AbLegacyDataType` covers the corresponding PCCC types: `Bit`, `Int` (N), `Long` (L), `Float` (F), `AnalogInt` (A), `String` (ST), and the `TimerElement` / `CounterElement` / `ControlElement` sub-element families. The parser enforces PCCC structural rules — bit-addressing only on 16/32-bit element files, sub-elements only on T/C/R files, no file number on I/O/S — rejecting malformed addresses before they reach libplctag. **Bit-within-word writes** — B-file (`B3:0/0`), I/O image (`I:0/0`, `O:1/2`), N-file (`N7:0/3`), and L-file bit addresses all use the same parent-word read-modify-write (RMW) path: the driver reads the parent word, flips the target bit, and writes the word back, under a per-parent lock. The lock serialises the driver's own concurrent bit-writers against the same word, but cannot guard against the PLC program writing that word between the driver's read and write — this is an inherent limitation of any software-level RMW. Input-image (`I`) bit writes are forwarded to the PLC as-is; the device drives the input image from physical inputs and may reject the write. ### Family-specific address forms (#244 / #245 / #247) `AbLegacyAddress.TryParse(text, family)` is family-aware; the driver passes the device's `PlcFamily` (the one-argument overload is the family-neutral grammar: decimal, no function files). - **PLC-5 octal I/O (#244).** RSLogix 5 shows `I:`/`O:` word and bit indices in octal, so `I:017/07` is word 15, bit 7 and `I:017/17` is bit 15. Only `I`/`O` on PLC-5 (`OctalIoAddressing` on the family profile) — SLC, MicroLogix and Logix-PCCC stay decimal, and PLC-5 `N`/`B`/... files stay decimal. The record stores decimal and `ToLibplctagName()` **emits decimal**, so the libplctag `name=` string and the string the native transport re-parses carry identical numbers. This is not a double conversion: libplctag's `pccc.c` (`parse_pccc_elem_num` / `parse_pccc_bit_num`) parses decimal numerals only (a FIXME there says octal is unsupported), so the driver must convert. Pinned by `Plc5_io_indices_are_octal` (`I:017/17` -> `I:15/15`). The connectivity-probe address is normalised the same way — by both the driver's probe loop and the Test Connect probe, through one helper (`AbLegacyProbeAddress.Normalise`, #660); an unparseable Test Connect probe address fails cleanly ("not a valid AbLegacy address") instead of reaching the transport. Normalisation yields the canonical name; the libplctag `S:0` → `S2:0` translation (#659) happens downstream in the runtime, so the two compose. - ⚠️ **Behaviour change / migration.** Before this change a PLC-5 `I:`/`O:` address was passed to libplctag as written, i.e. read as **decimal**. An address copied from RSLogix in octal (e.g. `I:017`) therefore read the wrong word, and an authored decimal such as `I:1/12` now means bit 10 (octal 12). A previously "working" address containing the digits 8 or 9 (`I:018`, `O:1/9`) is no longer valid on PLC-5. Re-author any PLC-5 I/O tags in RSLogix octal. Such a tag is refused (see below), never silently misread. - **A malformed / family-invalid address** (also: a function-file letter off MicroLogix) reads and writes `BadNodeIdUnknown` for that tag only, creates no runtime, leaves driver health alone, and logs **one** Warning per tag. The same check (`AbLegacyAddress.Validate`) runs in the AdminUI tag editor (family-independent: rejects only what *no* family accepts) and the deploy gate (`EquipmentTagConfigInspector`, using the device's `DeviceConfig.PlcFamily`, absent = SLC 500, unrecognised = unknown) — so `I:018` on a PLC-5 device warns at deploy (an error under `TagConfigValidationMode.Error`), and the editor is never stricter than the gate. - **MicroLogix function files (#245).** `RTC`, `HSC`, `DLS`, `MMI`, `PTO`, `PWM`, `STI`, `EII`, `IOS`, `BHI` (single fixed instance — no file number — with a `.SUBELEMENT`; no bit index) parse on MicroLogix devices only. `AbLegacyFunctionFile.SubElementType` carries the sub-element catalogue (flags → Boolean, words → Int32; unknown members default to Int32). ⚠️ **Wire-unverified on both transports:** there is no capture and no simulator coverage; the libplctag path passes the name through and the native transport has no file-type code for them, so it refuses the tag as `Unsupported` (`BadNotSupported` to the client; see Wire evidence). - **Indirect addressing (#247 parse, #646 runtime).** `N7:[N7:0]` (word from another address) and `N[N7:0]:5` (file from another address) parse, one level deep (the inner address must be direct, and carries the family's octal rule). At runtime the driver reads the inner address first (same runtime factory, so libplctag and Native alike), requires an integer in range (word/element `0..255`, or `0..999` on PLC-5/LogixPccc; file `0..999`), composes the concrete address and runs the normal read/write path against it. **The index is re-read on every read/write - never cached** - and the previous resolution's handle is released when the index moves. A bad index (negative, too large, float/string source) is `BadOutOfRange` for that tag only (no health impact); an inner-read failure propagates its own status (a wire failure also degrades health like any read). **Writes** resolve then write and are **not atomic**: the index can change between the two PLC accesses. Bit RMW writes (`B3:[N7:0]/4`) resolve first, then RMW the concrete word. The Native transport never sees the bracket form (it receives the composed name), so N-file indirect stays inside the wire-proven envelope; other file types are as (un)verified as their direct forms. Live-gated on `ab_server` by `AbLegacyIndirectAddressingTests` (`N7:0=5`, `N7:5=42`, `N7:[N7:0]` reads 42). ## Configuration `AbLegacyDriverOptions` (`Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs`) binds from the driver's `DriverConfig` JSON: - **`Devices`** — one `AbLegacyDeviceOptions` per PLC (`HostAddress`, `PlcFamily`, optional `DeviceName`). - **`Tags`** — pre-declared `AbLegacyTagDefinition` list (`Name`, `DeviceHostAddress`, `Address`, `DataType`, `Writable`, `WriteIdempotent`). - **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeAddress`. - **`Transport`** — absent (default ⇒ `Libplctag`), `Native` (opt-in), or `Libplctag`; see [Transport](#transport-transport-option). Full per-field descriptions live in the contracts assembly. The JSON skeleton is reproduced in [docs/v2/driver-specs.md §4](../v2/driver-specs.md). ## Transport (`transport` option) Same switch as AB CIP: `transport` = absent \| `Native` \| `Libplctag`, a string (case-insensitive on read, written as `"Libplctag"` / `"Native"`; "use the default" is encoded by omitting the key). `Native` is a managed PCCC-over-EtherNet/IP client (`Driver.Ab.Transport`). **Default resolution** (one place, `AbLegacyTransportResolver`, used by the driver, the Test Connect probe and the CLI): an absent key resolves to **`Libplctag` for every PLC family** — unchanged from before the native transport existed. **`Native` is opt-in** (final review I3; since 2026-10-09 it is proven on one real MicroLogix 1400 across S/N/F/B/T/C/R/ST/O/I reads and N/F/B-bit writes, but not on SLC, PLC-5 or `logixpccc` — see [Wire evidence](#wire-evidence-native-pccc)). An explicit value always wins: explicit `Native` with a PLC-5 / `LogixPccc` device is allowed (an operator choice) and logs one Warning per driver instance naming the limitation. (AbCip's node-level `Drivers:AbCip:DefaultTransport` has no AbLegacy counterpart — the AbLegacy default is libplctag and is not node-configurable.) A change takes effect when the driver instance is constructed: on a deploy that changes `DriverConfig` (which recreates it) **and on every process start**, including the restart that installs this build (see the release note). The AdminUI form's **Transport** dropdown offers "Default (libplctag)" (omits the key on save), "Native (managed, opt-in)" and "libplctag". The driver logs `transport= (default|configured)` on every init, e.g. `transport=Libplctag (default)`. CLI: `--transport default|native|libplctag` (default `default` = libplctag for every `--plc-type`). See [AbCip.md §Transport](AbCip.md#transport-transport-option) for the shared behaviour (one pooled connection per device, Forward Open throttle, routing). Native routing: SLC500/MicroLogix/PLC-5 are direct; `logixpccc` uses UnconnectedSend and its SLC framing is **unverified**. ### Release note — native PCCC transport, opt-in (final review I3) - **The default is unchanged: libplctag.** A driver config with no `transport` key runs `Libplctag`, for every PLC family, exactly as before this release. The upgrade restart moves nothing. (An interim build on this branch defaulted SLC 500 / MicroLogix to Native; that was reverted before merge.) - **`"transport": "Native"` opts a driver into the managed PCCC-over-EtherNet/IP client** (`Driver.Ab.Transport`). It takes effect when the driver is constructed — a deploy that changes `DriverConfig`, or a process start — not on `ReinitializeAsync`. The AdminUI **Transport** dropdown authors it; remove the key (or pick "Default (libplctag)") to go back. - **Known limits of Native** (why it is opt-in): - ⚠️ **Real-hardware evidence is one MicroLogix 1400** (Z-33323, 2026-10-09) — see [Wire evidence](#wire-evidence-native-pccc): S (incl. `S:0`), N, F, B, T, C, R, ST, O, I reads, T/C/R sub-elements and status bits, and N/F/B-bit writes all correct. **L and A files, function files, SLC 5/05, PLC-5 and `logixpccc` are unverified.** Opt in per driver after checking it against the real controller. - ⚠️ **Status-bit writes are refused** — `T4:0.DN`, `T4:0.TT`, `T4:0.EN`, `C5:0.CU`, `C5:0.DN`, `R6:0.EN` and the rest return **`BadNotSupported`** whatever the tag's authored data type (the refusal keys on the address); nothing is written to the PLC; health is unchanged. The driver answers the refusal **before it creates the tag's runtime**, so a first-touch write sends nothing at all — not even the init read, which against a controller without that file used to surface as `BadCommunicationError` + Degraded health (#593 follow-up). PLC-5 writes are refused the same way. libplctag performs a masked bit write here; a native read-modify-write of the control word would race the PLC updating TT/DN. Reads of status bits are unaffected. - ⚠️ **On libplctag (the default) a status-bit write is NOT refused** — it goes to the controller as a write to the timer/counter element (libplctag's masked bit-tag write; not exercised on a wire — `ab_server` has no T/C/R files). This covers the status bits libplctag parses itself (`T` `EN/TT/DN`, `C` `CU/CD/DN/OV/UN`); the ones it has no mnemonic for — every `R` status bit and `C…UA` — **are** refused on libplctag too (`BadNotSupported`, #659). Nothing in authoring prevents it: the address is free text, any `dataType` may be chosen (a status bit is naturally authored `Bit`), and `writable` defaults to `true`. Author status-bit tags with `"writable": false` unless writing them is genuinely intended. - **PLC-5 native is N-file reads only**; every other PLC-5 operation is refused with `BadNotSupported`. `LogixPccc` framing is unverified. Explicit Native on either logs a Warning at init. - ST (string) byte order is verified on a real MicroLogix 1400 (`ST14:10` = `"Z-33323"`). - Unlike AbCip, the native AbLegacy runtime has **no element-type check**, so a mis-authored same-width type reads as reinterpreted data (as on libplctag), not `BadTypeMismatch`. - Releasing a native tag (evict, reinitialize, shutdown) never blocks on the connection close: the lease is released in the background (`ConnectionLease.ReleaseInBackground`), the same shape as native AbCip (#579). A re-create cannot overlap the close, because the pool holds the successor connection behind it. ### Wire evidence (native PCCC) **Real controller (2026-10-09, user-directed):** MicroLogix 1400 **Z-33323** (`ab://10.201.44.231/`, empty path), chosen because it was idle (`N10:150/0` cycle-in-progress and `B3:0/4` false, pump input `I:0/0` off, `O:0` = 0 before and after). Read pass, each address on libplctag then native (raw output in [the gate-results doc](../plans/2026-10-07-native-ab-transport-gate-results.md)): | Address(es) | libplctag | Native | |---|---|---| | `S:0` (default probe), `S:57` (= 1400), `S:4` | Good, equal (4, 1400, clock) since #659; **BadCommunicationError** before | Good (4, 1400, clock) | | `N9:0`, `N9:4`, `N7:255` | Good | Good, equal | | `F8:226` (2.1), `F8:214` (200.0) | Good | Good, equal | | `B3:24`, `B3:28` (0x0905), bits `/0`, `/8`, `/9`, `B3:132/14` | Good | Good, equal | | `T4:15/93/14/11.PRE`, `.ACC`, `.DN`; `T40:3/12.PRE` | Good | Good, equal | | `C5:0.PRE/.ACC`, `C5:1.PRE`, `C5:8.DN` | Good | Good, equal | | `R6:0.LEN`, `.POS` | Good | Good, equal | | `R6:0.DN` (and `.EN`) | Good, equal since #659; **BadCommunicationError** before | Good | | `ST14:10` (`"Z-33323"`), `ST14:0` | Good | Good, equal — **ST byte order correct** | | `O:0`, `O:0/3`, `I:0`, `I:0/0`, `I:0/1`, `I:0/6`, `I:0/15` | Good, equal since #659; **BadCommunicationError** before | Good | Native writes on the same idle press, each restored to 0 and verified: `N7:23` 0→4242→0 (neighbours `N7:22/24` unchanged), `F8:190` 0→42.5→0, `B3:200` bit RMW `/5` set → 32, `/3` set → 40, `/5` clear → 8, `/3` clear → 0 (neighbours `B3:199/201` unchanged). Addresses vetted against the sibling Z-33756 program export (no ladder, symbol, HMI, panel or migration reference) and Z-33323's own symbol table, and read 0 beforehand. **libplctag `S`/`O`/`I` and control status bits — fixed (#659).** Before the fix libplctag (the default) returned `BadCommunicationError` for `S:`, `O:`, `I:` and `R6:0.DN`/`.EN` on this controller, so the default probe `S:0` failed on real hardware. Root cause, from libplctag 2.6.0's `src/protocols/ab/pccc.c` (the core bundled by the `libplctag` 1.5.2 package): `parse_pccc_file_num` requires a digit after the file letter (and `S` is only the status file when a digit follows it), and its sub-element table has only `con`/`len`/`pos` for R files — a bare `R6:0/13` is refused too, because a `/bit` is only allowed on a 2-byte element. The libplctag adapter (`LibplctagLegacyName`) now translates the canonical address into the form that parser accepts — `S:n` → `S2:n`, `O:n` → `O0:n`, `I:n` → `I1:n` (the same default file numbers the native encoder uses, on every family) and a status bit libplctag lacks (`R` `EN/EU/DN/EM/ER/UL/IN/FD`, `C` `UA`) → `R6:0.CON/` (control word + bit, bit numbers from the native table). The authored grammar is unchanged; `S2:0`/`O0:0`/`I1:0` stay rejected by the parser (one spelling per address). Re-run on the same idle press 2026-10-09: every address above reads **identical** on both transports (`S:4` is a free-running clock and differs read to read on each). **The translation is read-side only — it does not widen the write surface:** before it, libplctag could not create a tag for `S:`/`O:`/`I:` or for an `R`/`C…UA` status bit, so no write to one ever reached the PLC. Now that the translated names parse, a write would drive a physical output, a status-file word or (masked) a control word. So on libplctag **every write whose address the translation changes** — `S:`, `O:`, `I:` (word and bit writes; a bit write such as `O:0/3` is refused ahead of the parent-word read-modify-write) and the rewritten status bits (`R` `EN/EU/DN/EM/ER/UL/IN/FD`, `C` `UA`) — is refused **`BadNotSupported` before a runtime is created**: nothing read or sent, health unchanged (`LibplctagLegacyTagRuntime.WriteRefusal`, asked through `IAbLegacyTagFactory.WriteRefusal`, the same plumbing as the native refusal). Those writes never worked on libplctag, so nothing is lost; the native transport (opt-in) does write `S`/`O`/`I`. T/C status bits libplctag parses itself keep their pre-existing write behaviour (note above). Not tested on hardware: L and A files (no press has them), function files, PLC-5, SLC, `logixpccc`. Simulator evidence: | What | Evidence | |---|---| | N-file read (`N7:0`) | libplctag captures `slc500-read-n7`, `micrologix-read-n7`, `plc5-read-n7` (codec byte parity) + native `AbLegacyReadSmokeTests` read on all three profiles | | N-file write (`N7:5`) | capture `slc500-write-n7` + native `AbLegacyReadSmokeTests` write/read round trip (SLC 500) | | F-file read (`F8`) | one capture, `slc500-read-f8` (codec parity only; no native F write on a wire) | | S (incl. `S:0`), B, T, C, R, ST, O, I | **real MicroLogix 1400 (above)**; L and A: none | | MicroLogix function files (RTC/HSC/...), PLC-5 octal I/O | **none** — parsed only; native refuses function files as Unsupported | | Bit-within-word write (`B3:0/5`) | `ab_server` SLC500 + MicroLogix, both transports (`AbLegacyBitWriteTests`): the driver RMWs the parent word, only the addressed bit changes (the parent-word read-modify-write is not atomic against ladder writes to the same word). PLC-5 native refuses it (`BadNotSupported`, #571) | | Indirect addressing (`N7:[N7:0]`) | `ab_server` N-file, both transports (#646) - resolved by the driver, the transport sees the composed address | | T/C/R sub-elements (`.PRE`/`.ACC`/`.LEN`/`.POS`, status bits) | **real MicroLogix 1400 (above)** | ### Native known gaps - ⚠️ **One real controller.** A MicroLogix 1400 (Z-33323) passed reads across S/N/F/B/T/C/R/ST/O/I and N/F/B-bit writes; SLC 5/05, PLC-5, `logixpccc`, L/A files and function files are unverified. See [Wire evidence](#wire-evidence-native-pccc). - **PLC-5 native is N-file reads only.** Every other file type and all PLC-5 writes are refused before the wire and reach the client as `BadNotSupported` (handle kept, health unchanged; #571 closed). Native is opt-in for every family; an explicit Native on PLC-5 / `logixpccc` logs a Warning at init. - Status-bit sub-element writes (e.g. `T4:0.DN`, `C5:0.DN`) are Unsupported natively (they would clobber the control word); nothing goes on the wire — the driver refuses before creating the tag's runtime, so not even an init read is sent (`NativeAbLegacyTagRuntime.WriteRefusal` is the single predicate, asked through `IAbLegacyTagFactory.WriteRefusal`). Pinned offline by `NativeAbLegacyRefusedWriteTests` and on `ab_server` by `AbLegacyUnsupportedPathTests` (whose SLC500 fixture has no T/C/R files, which is what makes it discriminating). Sub-element arrays are Unsupported natively. L-file bit indexes above 15 are allowed. - Capture recipe for golden frames: `scripts/ab-capture/capture.sh` (see [AbLegacy-Test-Fixture.md](AbLegacy-Test-Fixture.md)). Follow-up issues: #564 to #577. ## Testing - **Unit tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` cover the driver, the PCCC address parser, and the host-address parser via fake tag runtimes. - **Integration tests** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/` run against the AB Legacy Docker fixture. See [AbLegacy-Test-Fixture.md](AbLegacy-Test-Fixture.md) for the coverage map. - **Manual client** — [Driver.AbLegacy.Cli.md](../Driver.AbLegacy.Cli.md). ## 1-D array support An AB Legacy tag becomes a **1-D OPC UA array node** when its `TagConfig` JSON carries `"isArray": true` and `"arrayLength": N` (N ≥ 1, capped at 256). The canonical rule: `isArray: true` + `arrayLength >= 1` → array; `isArray: false` (any length) → scalar. **Read mechanism** — the driver issues a **PCCC multi-element file read** (libplctag by default; the opt-in native transport issues the same typed read, chunked under the PCCC payload limit). The tag's file address (e.g. `N7:0`) is extended by `N` element count so the transport fetches `N` consecutive PCCC file elements in one EIP/PCCC request. The response is decoded into N values of the declared `AbLegacyDataType`. Maximum element count is 256 (PCCC payload limit). This closes the previously-noted limitation ("single-element addressing today") for the array read path. The opt-in native transport also writes multi-element arrays (chunked); on libplctag array *writes* remain a follow-up. **Unit test coverage** — `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/` covers the multi-element read path via the fake tag runtime. The AB Legacy integration fixture is down during normal dev. **Live-verify** — integration-fixture-gated (requires the AB Legacy Docker fixture). **Deferrals** — array *writes* on libplctag (the opt-in native transport writes them), multi-dimensional arrays, per-element historization. `arrayLength > 256` is rejected at init with an explicit error (PCCC payload cap). See [Uns.md §Array tags](../Uns.md#array-tags-1-d) for the cross-driver coverage matrix and the UI authoring flow. ## Operational Notes - **Native heap is invisible to the GC.** As with AB CIP, libplctag's native heap does not show up in CLR allocation figures; watch whole-process RSS and use `ReinitializeAsync` to recycle libplctag handles. (`IDriver.GetMemoryFootprint()` was deleted as unconsumed in Gitea #525.) - **PCCC reconnect is more expensive than CIP** — legacy PLCs have no connection multiplexing, so the resilience pipeline should use longer backoff than for AB CIP (see [docs/v2/driver-specs.md §4](../v2/driver-specs.md)). - **Single-element addressing today** — a PCCC file is inherently an array (an N7 file is up to 256 words), but the current tag surface addresses one element per tag; range-spanning tags must be enumerated element-by-element until multi-element addressing lands.