Files
lmxopcua/docs/drivers/AbLegacy.md
T
Joseph Doherty 1806af54f0 fix(ab): Task 22 review r1 — release-note timing and AbLegacy regressions, probe E2E on both transports
- I1: the upgrade restart itself moves every no-key AbCip/AbLegacy driver to Native (every process
  start constructs drivers, incl. boot-from-cache). Docs + CLAUDE.md now say so and tell operators
  to set "transport": "Libplctag" BEFORE upgrading (older builds ignore the unknown key).
- I2: AbLegacy release note leads with the status-bit write refusal (T4:0.DN, C5:0.CU, R6:0.EN, ...)
  on defaulted SLC/MicroLogix drivers and the fallback. The real client status is
  BadCommunicationError, not BadNotSupported (the driver's write catch-all maps the runtime's
  AbTransportException like any transport failure, as #571 does for PLC-5), with handle eviction
  and Degraded health; pinned by a driver-level test (no 0xAA write on the wire, with a positive
  control). ST string byte order stated as unverified. Also corrects the docs' "T4:0/DN" examples:
  the driver's address grammar is "T4:0.DN" ("/DN" is a malformed address).
- I3: DriverProbeHandshakeE2eTests' two AbCip probe tests are a [Theory] over Native and
  Libplctag. Run against ab_server controllogix: all 4 rows pass with identical exact messages.
- M1: documented (docs + resolver XML doc): the AdminUI merges only the probed device, so a
  mixed-family defaulted AbLegacy driver can probe Native while running Libplctag.
- M2: factory ParseEnum rejects undefined integer strings ("7") via Enum.IsDefined (both drivers).
- M3: AbCip driver-level bad value reads "AB CIP config in '<id>' has unknown Transport ..."; the
  named constant is gone (tagName: null now means driver-level).
- M4: the explicit-Native limited-family Warning is once per instance, not per (re)init.
- Nit: EffectiveTransport docs note that injected factories bypass it.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-07 21:06:40 -04:00

15 KiB

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. For the manual test client, see Driver.AbLegacy.Cli.md. For the integration fixture coverage map, see 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 ⇒ Native, but Libplctag when any device is PLC-5 / LogixPccc), Native, or Libplctag (fallback); see Transport.

Full per-field descriptions live in the contracts assembly. The JSON skeleton is reproduced in docs/v2/driver-specs.md §4.

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 Native, unless any configured device has PlcFamily Plc5 or LogixPccc — then the whole driver resolves to Libplctag, because the transport is chosen once per driver, not per device. 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.

⚠️ Test Connect in a mixed-family driver can disagree with the runtime. The AdminUI probe merges the driver config with only the device being probed, so in a defaulted driver holding an SLC 500 and a PLC-5, Test Connect on the SLC device resolves Native while the deployed driver runs every device on Libplctag. SLC 500 works on both stacks, so this is cosmetic; the reverse (probe on libplctag, driver on native) cannot happen. Set transport explicitly to make them agree.

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 (Native; libplctag for PLC-5 / LogixPccc)" (omits the key on save), "Native (managed)" and "libplctag (fallback)". The driver logs transport=<effective> (default|configured) on every init, e.g. transport=Libplctag (default; PLC-5/LogixPccc device present). CLI: --transport default|native|libplctag (default default, so --plc-type Plc5 with no --transport runs libplctag). See AbCip.md §Transport 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 is the default (Task 22)

  • ⚠️ Status-bit writes are refused on the default. An SLC 500 / MicroLogix driver on the native default refuses writes to status-bit sub-elements — T4:0.DN, T4:0.TT, T4:0.EN, C5:0.CU, C5:0.DN, R6:0.EN, and the rest. The client sees BadCommunicationError (not BadNotSupported: the driver's write path maps the refusal like any transport failure, as for the PLC-5 refusal in #571), the tag's handle is evicted, and driver health goes Degraded with "Writes to a status-bit sub-element … are not supported by the native transport". Nothing is written to the PLC. libplctag performed a masked bit write here, so an authored writable status-bit tag that worked before the upgrade fails after it. This is deliberate: a native read-modify-write of the control word would race the PLC updating TT/DN and could clobber EN/TT/DN, and such writes are rare. Drivers that need them must set "transport": "Libplctag". Reads of status bits are unaffected. Word sub-elements (T4:0.PRE, T4:0.ACC, R6:0.LEN, R6:0.POS) still write natively.
  • The native managed transport is now the default for SLC 500 and MicroLogix. A driver config with no transport key runs Native; libplctag remains available as the fallback.
  • Exclusion: a defaulted driver with any PLC-5 or LogixPccc device stays on libplctag. Native PLC-5 support is N-file reads only, and LogixPccc framing is unverified. Explicit Native is still honoured there, with a Warning at init.
  • ⚠️ Client-visible: native reports BadTypeMismatch for a mis-authored same-width type, where libplctag returned Good garbage (see AbCip.md §Native transport behaviour).
  • ST (string) files: the native ST file byte order is unverified (no capture), so string values read or written natively may be byte-swapped. Drivers with ST tags whose values matter should fall back.
  • To fall back, set "transport": "Libplctag" in the driver config.
  • ⚠️ 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 Native the moment the upgraded node starts — no deploy is needed. To keep a driver on libplctag, add "transport": "Libplctag" and deploy it before upgrading: older builds ignore the unknown key. After the upgrade, a transport change takes effect when a deploy recreates the driver (not on ReinitializeAsync).
  • Evidence, and its limits: all of AbLegacy is simulator-proven only (ab_server). No real SLC, MicroLogix or PLC-5 has been exercised. (The real-press gate, Z-35162 2026-10-08, covered AbCip reads only.)

Native known gaps

  • ⚠️ Simulator-only. PCCC reads and writes are verified against ab_server only. 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, currently surfacing as BadCommunicationError rather than BadNotSupported (#571). The default excludes PLC-5 and logixpccc (they resolve to libplctag unless Native is set explicitly).
  • 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). 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 for the coverage map.
  • Manual client — 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 via libplctag. The tag's file address (e.g. N7:0) is extended by N element count so libplctag 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. 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, multi-dimensional arrays, per-element historization. arrayLength > 256 is rejected at init with an explicit error (PCCC payload cap).

See Uns.md §Array tags 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).
  • 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.