Files
lmxopcua/docs/drivers/AbLegacy.md
T
Joseph Doherty 39ae286cd7 fix(ab): a refused operation is BadNotSupported, not a comms fault (Task 22 review r1; closes #571)
Native is the default, so a locally refused write must not look like a comms fault.

- AbLegacy: an AbTransportException with AbStatus.Unsupported (status-bit sub-element writes, PLC-5
  non-N reads, all PLC-5 writes; libplctag's ErrorUnsupported maps here too) now returns
  BadNotSupported on both the read and the write path (incl. the bit-RMW path). The tag handle is
  not evicted and driver health is not touched. A native runtime whose init read is refused locally
  is CACHED rather than disposed, so a poll of a refused tag does not re-create (and re-lease the
  pooled connection) every cycle.
- AbCip: the same mapping on the write path and both read paths (single + whole-UDT).
- Uses the existing named BadNotSupported constants (StatusCodeParityTests guard).
- Tests (falsified by disabling the mapping, and separately the keep-on-refusal): status-bit
  writes and PLC-5 read/write through the driver over the native runtime — BadNotSupported, zero
  wire requests, one create, health unchanged; AbCip refused read/write via a refusing fake plus a
  control that a non-refusal failure still evicts and degrades.
- Docs/CLAUDE.md: BadNotSupported; #571 known-gap wording removed.

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

213 lines
15 KiB
Markdown

# 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 ⇒ `Native`, but `Libplctag` when any device is PLC-5 / `LogixPccc`), `Native`, or `Libplctag` (fallback); 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 `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](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 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 **`BadNotSupported`**. It is not treated as a comms fault: the
tag's handle is kept and driver health is unchanged. **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](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 before the
wire and reach the client as `BadNotSupported` (handle kept, health unchanged; #571 closed). 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](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** 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](../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.