# 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) | `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. ## 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: on a wire it is proven only for N-file reads and writes and one F8 read — 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): - ⚠️ **Wire evidence is narrow and simulator-only** — see [Wire evidence](#wire-evidence-native-pccc). Only N-file reads and writes and one F8 read have touched a wire (`ab_server`). The **default probe address `S:0`** and the **B, T, C, R, S, L, ST, A, O and I file types** — and the T/C/R sub-element offsets (`.PRE`/`.ACC`/`.LEN`/`.POS`) — are encoded from the PCCC file-type table and are **unverified natively**. No real SLC, MicroLogix or PLC-5 has been exercised. 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`**; nothing is written to the PLC; the handle is kept and health is unchanged. 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. - **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 unverified** (no capture); string values may be byte-swapped. - 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) Everything below is against the `ab_server` simulator; no real PCCC controller has been exercised. | 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. the default probe `S:0`), B, T, C, R, L, ST, A, O, I | **none** — encoded from the PCCC file-type table | | T/C/R sub-elements (`.PRE`/`.ACC`/`.LEN`/`.POS`), 6-byte T/C/R element layout | **none** | ### Native known gaps - ⚠️ **Simulator-only, and narrow.** Only N-file reads/writes and one F8 read are on a wire (`ab_server`); see [Wire evidence](#wire-evidence-native-pccc). No real SLC/MicroLogix/PLC-5 has been exercised; the real-press gate (Task 21) was read-only and AbCip-only. - **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. Sub-element arrays are Unsupported natively. L-file bit indexes above 15 are allowed. - **ST (string) file layout is uncaptured**, so the string byte order is unverified natively. - 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.