39ae286cd7
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
213 lines
15 KiB
Markdown
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.
|