docs(plans): native .NET EtherNet/IP+CIP+PCCC transport to replace libplctag under AbCip/AbLegacy — design

This commit is contained in:
Joseph Doherty
2026-10-07 14:35:14 -04:00
parent 9dbaa6d758
commit 45b6062de5
@@ -0,0 +1,265 @@
# Native .NET transport for AbCip + AbLegacy (replace libplctag) — design
**Date:** 2026-10-07 · **Branch:** `feat/native-ab-transport` · **Status:** design, awaiting review
## 1. Goal
Replace the `libplctag` NuGet (managed wrapper + native `plctag` binary, pinned `1.5.2`) under the
AbCip and AbLegacy drivers with a fully managed EtherNet/IP + CIP + PCCC client owned by this repo.
libplctag is kept, behind a switch, as a fallback until the native path is proven.
### Why (all four apply)
1. **Native-binary pain** — RID-specific packaging, container/ARM images, an unmanaged dependency in
an otherwise all-.NET 10 server.
2. **Correctness and control** — wrapper defects we cannot fix: `ElementSize` returns `null` after a
read (`68cf3ae8`), there is no `ConnectionSize` property (the value is plumbed and dropped),
status codes are opaque, and libplctag never checks the CIP type the controller returns (`e3d6b76e`
could only add a width check).
3. **Performance** — one native tag handle per `(device, tag)`; no control over request packing or
scheduling.
4. **Policy** — no native dependencies; libplctag's LGPL/MPL licensing.
### Non-goals
- No change to the driver surface above the transport seams: the address space, tag-config JSON,
the poll engine, the RMW serialization, the backoff, and the decoders all stay.
- No new PLC families. No CIP Class-1 (implicit I/O) connections. No Omron (`omron-njnx` stays an
unmapped libplctag value only while the fallback exists).
- Whole-UDT **writes** stay unsupported, as they are today.
## 2. Decisions (all taken at the recommended option)
| # | Question | Decision |
|---|---|---|
| D1 | Build or buy | **Build** an in-repo managed stack. Existing managed CIP libraries fail the licensing/control goals. |
| D2 | Cutover | **Side-by-side behind a per-driver `Transport` switch** (`Native` \| `Libplctag`), the same dark-switch discipline as `MeshTransport` / `ConfigSource`. |
| D3 | Proof standard | **Flip per evidence tier** (§8). Native becomes default for AbCip once reads/browse/templates pass the real presses read-only; AbCip writes and all of AbLegacy flip on simulator proof, recorded as a known gap. libplctag stays as the fallback until a bench controller exists. |
| D4 | Where the stack lives | A new shared project, **`ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport`**, referenced by both drivers (precedent: `Mqtt.Transport`, `OpcUaClient.Session`). Pure protocol: no OtOpcUa, no OPC UA, no logging-framework dependency beyond `Microsoft.Extensions.Logging.Abstractions`. |
| D5 | Connection model | **One CIP connection per device** (`host:port` + CIP path), shared by every tag runtime on that device. libplctag already shares internally; the native stack makes it explicit. |
| D6 | Request packing | **Transparent coalescing inside the connection** into Multiple Service Packet (0x0A) requests, within the negotiated connection size, when `AllowPacking`. The `IAbCipTagRuntime` interface does not change. |
| D7 | Status neutrality | A **driver-owned `AbStatus` enum + `AbTransportException`** replace libplctag's `Status` / `LibPlcTagException` in the mappers, probes and `AbCipDriver`. The libplctag adapter translates into them. |
| D8 | Decoder reuse | The native enumerator and template reader **emit the same byte layout libplctag's `@tags` / `@udt/{id}` pseudo-tags return**, so `CipSymbolObjectDecoder` / `CipTemplateObjectDecoder` and their real-capture tests are shared unchanged, and the two transports can be diffed byte for byte. |
| D9 | Primary correctness evidence | **Differential testing**: the same reads through both transports against the same target (`ab_server`, then the real presses read-only), compared on raw buffer and decoded value. |
## 3. Architecture
```
AbCipDriver / AbLegacyDriver (unchanged except factory selection + AbStatus)
│ IAbCipTagFactory / IAbCipTagEnumeratorFactory / IAbCipTemplateReaderFactory / IAbLegacyTagFactory
├── Libplctag* adapters (existing; now translate Status → AbStatus) ← fallback
└── Native* adapters (new, in each driver project)
│
▼
ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport (new, pure managed)
Eip/ EncapsulationClient — TCP framing, RegisterSession/UnRegisterSession,
ListIdentity, SendRRData (UCMM), SendUnitData (connected)
Cip/ CipPath (port segments from "1,0", ANSI symbolic 0x91, member/element
segments 0x28/0x29/0x2A), CipService, CipGeneralStatus + extended status
Cip/ ConnectionManager — Forward Open (0x54) / Large Forward Open (0x5B) /
Forward Close (0x4E), sequence counts, connection-size negotiation,
UCMM-only mode (Micro800)
Cip/ RequestScheduler — per-connection queue, MSP coalescing, in-flight bound,
per-request timeout + cancellation
Logix/ Read Tag (0x4C) / Read Tag Fragmented (0x52) / Write Tag (0x4D) /
Write Tag Fragmented (0x53); Logix type codes incl. struct handle (0xA0)
Logix/ SymbolLister (Get Instance Attribute List 0x55 on class 0x6B, paged)
Logix/ TemplateReader (Get Attribute List 0x03 + Read 0x4C on class 0x6C, chunked)
Pccc/ Execute PCCC (0x4B, class 0x67) with requestor id; SLC/MicroLogix protected
typed logical read/write (0x0F/0xA2, 0x0F/0xAA); PLC-5 typed read/write
(0x0F/0x68, 0x0F/0x67); PCCC STS/EXT STS
DeviceSessionPool — one session+connection per device key, ref-counted
```
### 3.1 Units and their contracts
- **`EncapsulationClient`**: owns one TCP socket. It sends and receives 24-byte-header encapsulation
frames and correlates replies by sender context. It knows nothing about CIP. Testable against canned
byte streams.
- **`CipPath`**: a pure encoder. It turns `"1,0"` (and `"1,0,2,10.0.0.5"` style routes) into port
segments, and turns a Logix tag name such as `Program:P.Arr[3].Member.5` into symbolic, element and
member segments. `AbCipTagPath` already parses names; `CipPath` encodes them. Golden-byte unit tests.
- **`ConnectionManager`**: opens and closes the connected session for a device.
- **Connection size.** It honours `ConnectionSize` (the field libplctag drops today). Above 511
bytes it uses Large Forward Open. If the target rejects a large open (status 0x01, extended 0x0109
or 0x0111), it falls back to a regular Forward Open at the size the target advertises.
- **Micro800** (`SupportsConnectedMessaging = false`) runs UCMM-only: no Forward Open.
- **`RequestScheduler`**: one queue per connection.
- **Coalescing.** Requests that arrive within one scheduling turn are packed into an MSP until the
next service would overflow the connection size. A reply is fanned back to each waiter with its own
per-service status.
- **Oversize requests.** A request too large for one frame goes through the fragmented service loop.
- **Bounds.** At most one in-flight connected request per connection by default (`MaxInFlight`,
default 1), the conservative choice for a production press.
- **`DeviceSessionPool`**: keyed by `(host, port, cipPath, familyAttribute)`. A runtime acquires a
lease on first `InitializeAsync` and releases it on `Dispose`. The last release sends Forward Close
and UnRegisterSession.
### 3.2 Native adapters (in the driver projects)
- **`NativeAbCipTagRuntime : IAbCipTagRuntime`**
- **Reads.** `InitializeAsync` acquires the device lease and resolves the encoded path once.
`ReadAsync` issues Read Tag with `ElementCount`. The reply's **CIP type code + struct handle** is
kept with the buffer.
- **Decoding.** `Decode*` reads the managed buffer, little-endian. Strings use the Logix
`LEN(u32) + DATA[n]` layout with the stride taken from the reply. BOOL arrays are 32-bit packed.
- **Size and status.** `GetSize()` is the real reply length, so the stride fallback in
`ResolveArrayStride` stops being necessary. `GetStatus()` returns `AbStatus`.
- **Writes.** `WriteAsync` issues Write Tag with the type taken from the last read, or from the
authored type for atomics. A struct-typed write (STRING) needs the struct handle, so a write with
no prior read performs one read first.
- **`NativeAbCipTagEnumerator`** reads controller-scope and `Program:X` listings through
`SymbolLister`, and emits the concatenated entry bytes in the `@tags` layout.
- **`NativeAbCipTemplateReader`** emits the 14-byte header that libplctag synthesizes, then the raw
template bytes (D8).
- **`NativeAbLegacyTagRuntime : IAbLegacyTagRuntime`**
- **Addressing.** Encodes `AbLegacyAddress` (file type, file number, element, sub-element) into PCCC
typed-logical addressing, or PLC-5 system addressing for `plc5`.
- **Arrays.** Multi-element reads use the element count.
- **Bits.** Bit-in-word writes stay at the driver level (`WriteBitInWordAsync`), as today.
### 3.3 The switch
- `AbCipDriverOptions.Transport` and `AbLegacyDriverOptions.Transport` take values
`Native | Libplctag`, are serialized as **strings** (`JsonStringEnumConverter`, per the
driver-enum-serialization trap), and are absent ⇒ the current default.
- `Ab*DriverFactoryExtensions.CreateInstance` picks the factory set from the option. Test-injected
factories still win.
- The **probe** (`Ab*DriverProbe`, i.e. Test Connect) honours the same option. A probe that tests a
different transport than the driver runs would be a lie.
- **AdminUI**: a `Transport` dropdown in the AbCip and AbLegacy driver-config modals under the `/raw`
tree. It needs live `/run` verification on docker-dev, since there is no bUnit.
- Default sequence: `Libplctag` at merge, flipped per §8, and the fallback stays selectable.
## 4. Data flow (a poll read)
1. The driver poll calls `runtime.ReadAsync(ct)` on N runtimes for one device. The concurrency
window is unchanged.
2. Each runtime enqueues a Read Tag request on the device's `RequestScheduler`.
3. The scheduler packs as many as fit into one MSP, sends it via SendUnitData on the shared
connection, awaits the reply, splits it by service, and completes each waiter with
`(status, typeCode, structHandle, payload)`.
4. The runtime stores the payload. The driver calls `Decode*` exactly as it does today.
The result: N round-trips through N libplctag handles become ⌈N / fit⌉ round-trips through one
connection. That is the performance win, with no driver change.
## 5. Error handling and status
- **`AbStatus`** (driver-owned) carries the values the mappers and probes use today: `Ok`,
`Pending`, `Timeout`, `NotFound`, `NotAllowed`, `OutOfBounds`, `TooLarge`, `TooSmall`,
`Unsupported`, `BadConnection`, `BadReply`, `Partial`, `Abort`, plus a new `TypeMismatch`.
- **CIP general status mapping (native):**
- `0x00` ⇒ `Ok`
- `0x04` / `0x05` (path segment / destination unknown) ⇒ `NotFound`
- `0x06` ⇒ `Partial` (continue fragmenting)
- `0x08` ⇒ `Unsupported`
- `0x0F` ⇒ `NotAllowed`
- `0x13` ⇒ `TooSmall`
- `0x15` ⇒ `TooLarge`
- `0xFF` ⇒ by extended status (`0x2105` ⇒ `OutOfBounds`, `0x2107` ⇒ `TypeMismatch`)
- socket, encapsulation or timeout faults ⇒ `BadConnection` / `Timeout`
- **PCCC mapping.** STS/EXT STS map the same way: address-does-not-exist ⇒ `NotFound`, illegal
command/format ⇒ `Unsupported`, and so on.
- **`AbTransportException(AbStatus, string detail)`** is thrown by both transports.
`AbCipDriver.cs:1188`'s "the controller answered, so don't throttle the device" check
(`IsReachableStatus`) keys on it. **That preserves 05/STAB-8 semantics.**
- **Type check, native only.** The reply's CIP type code is compared with the authored type.
- A mismatch ⇒ `BadTypeMismatch`. This now also catches **same-width** mistakes (DINT authored,
REAL returned) that `e3d6b76e` documents as undetectable.
- ⚠️ **This is client-visible.** Tags authored wrong from the legacy Kepware export that read Good
garbage under libplctag will read Bad under native. That is the fix working, but it must be
in the release note and in `docs/drivers/AbCip.md`.
- **Press safety.**
- **Reconnect.** A dropped connection is re-opened through the driver's existing `device.Backoff`,
never in a tight loop.
- **One open at a time.** `ConnectionManager` enforces at most one Forward Open in flight per
device, plus a minimum interval between opens (default 1 s).
- **Pinned by a test.** A regression test asserts a bounded Forward Open rate against a refusing
target. ignitionoee recorded **~15 Forward Opens/s** at a press from a misconfigured client.
- **Close.** Forward Close on dispose. The connection timeout multiplier and RPI are set so a dead
client's connection expires on the PLC within seconds, not minutes.
## 6. Packaging
- New project `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/` and its test project
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/`, both added to
`ZB.MOM.WW.OtOpcUa.slnx`.
- While the fallback exists, both drivers keep the `libplctag` package. Libplctag code is confined
to the `Libplctag*.cs` adapters. After phase 2 no other file has `using libplctag`, and a
source-scan guard test enforces that.
- On deletion (phase 6) the package, its `Directory.Packages.props` pin, the adapters and the
`Libplctag*` create-param members (`LibplctagGateway`; `LibplctagPlcAttribute` is renamed
`PlcAttribute`) go.
## 7. Testing
1. **Golden-frame unit tests** (`Ab.Transport.Tests`).
- **Encode** every frame type: RegisterSession, Forward Open / Large / Close, MSP, Read / Write
(Fragmented), 0x55 listing, template attribute reads, Execute PCCC.
- **Decode** every reply type, including error replies.
- **Fixtures** are byte captures taken once from libplctag talking to `ab_server` (pcap, stored as
hex), plus the **existing real-press captures** in `CipSymbolObjectWireCaptureTests` and
`CipTemplateObjectWireCaptureTests`.
- ⚠️ A capture from libplctag ⇄ `ab_server` proves wire parity with libplctag, not correctness. The
real-press captures are the independent reference (the #468 lesson: verify a fixture with the
reference client).
2. **Scheduler and connection tests.** These run against an in-process fake encapsulation endpoint.
They cover:
- MSP packing boundaries at connection size −1, exact and +1
- per-service error fan-out
- cancellation mid-MSP
- the Forward Open rate bound
- large-open fallback
- lease ref-counting and Forward Close on last release
3. **Integration suites run on both transports.** The existing `AbCip.IntegrationTests` and
`AbLegacy.IntegrationTests` against `ab_server` (controllogix / compactlogix / micro800 / guardlogix;
slc500 / micrologix / plc5) are parameterized over `Transport`. A native run must be green on every
test the libplctag run is green on.
4. **Differential harness.** A `LiveIntegration`-category test, env-gated like the HistorianGateway
live suite and skipped when the env vars are absent. It reads the same tag set through both
transports and asserts equal raw buffers and equal decoded values:
- against `ab_server`;
- against a real press, **read-only**. It runs browse (`@tags` + every program listing), every
template, and a sample of scalar, array, STRING and UDT-member reads.
5. **AdminUI `/run`.** Author `Transport: Native` on a docker-dev AbCip driver pointed at the
`ab_server` fixture, deploy, read, then flip it back.
## 8. Phased delivery and gates
| Phase | Delivers | Gate |
|---|---|---|
| **P0 Seam** | `AbStatus` + `AbTransportException`; mappers, probes, `AbCipDriver` and `AbLegacyDriver` decoupled from libplctag types; the 9 unit-test files that build `LibPlcTagException` / `Status` fakes (`AbCipDriverWriteTests`, `AbCipDriverProbeTests`, `AbCipDriverReadTests`, `AbCipStatusMapperTests`, `AbCipDriverCodeReviewRegressionTests`, `AbCipUnknownTagReadTests`, `AbLegacyEvictOnFailureTests`, `AbLegacyReadWriteTests`, `AbLegacyHostAndStatusTests`) moved onto `AbStatus` / `AbTransportException`, with the libplctag translation itself tested in the adapters; the `Transport` option (string enum) + factory selection, including a `--transport` flag on `otopcua-abcip-cli` / `otopcua-ablegacy-cli` (whose help text names libplctag); the source-scan guard | Existing suites green; behaviour identical (default `Libplctag`) |
| **P1 Transport core** | Encapsulation, `CipPath`, `ConnectionManager`, `RequestScheduler` (MSP + fragmentation), `DeviceSessionPool` | Golden-frame + scheduler tests |
| **P2 AbCip native** | `NativeAbCipTagRuntime`, enumerator, template reader; CIP type check | AbCip integration suite green on both transports vs `ab_server` |
| **P3 AbLegacy native** | PCCC layer + `NativeAbLegacyTagRuntime` | AbLegacy integration suite green on both transports vs `ab_server` (slc500 / micrologix / plc5) |
| **P4 Switch surface** | AdminUI dropdown, probe honours the switch, docs (`AbCip.md`, `AbLegacy.md`, fixture docs) | docker-dev `/run` |
| **P5 Real-hardware gate + flip** | Differential run against the CR presses read-only (VPN from the Mac); a perf comparison (round-trips + p50/p95 poll latency for one press's tag set, native vs libplctag); then **default ⇒ `Native` for both drivers** | AbCip: diff clean on browse / templates / reads, no worse latency, Forward Open count during the run = 1. AbLegacy: simulator proof only, **recorded as a known gap** |
| **P6 Delete libplctag** *(conditional)* | Remove the package, adapters and `Libplctag*` members | **Blocked until a bench controller** proves writes (CompactLogix or Micro800) and PCCC (SLC / MicroLogix). Until then libplctag stays selectable |
### Real-hardware constraints (from `~/Desktop/ignitionoee`)
- **Reachable targets:** 20 × CompactLogix **5069-L320ERS2 rev 34.11** presses at CR
(Z-35162..Z-35181; Z-35162 = `10.209.70.160:44818`), reachable from the Mac over VPN. This is
the same rig `cc876bdf` verified against.
- **They are production machines and strictly read-only.** P5 performs **no writes**, opens a
single connection per press, and touches one press at a time.
- **No legacy PCCC hardware is reachable.** The CR presses were confirmed non-MicroLogix by
ListIdentity. The Delmia inventory lists PLC-5/04s at Warsaw with no addresses, and as far as I
know a PLC-5/04 has no built-in Ethernet port. There is no ControlLogix and no Micro800 either. So
routed paths beyond `1,0` and Micro800 UCMM-only mode are simulator-proven only.
## 9. Known gaps and risks
- **AbCip writes are simulator-proven only.** Mitigation: Write Tag shares framing with the
read-proven path. The risk sits in the encoding details (STRING struct handle, type codes), which
have golden tests and run against `ab_server`. libplctag stays selectable.
- **AbLegacy is simulator-proven only**, against `ab_server`'s PCCC emulation, which shares
libplctag's view of the protocol. This is the weakest evidence in the plan, stated plainly.
- **`ab_server` patches** (`0001` symbol listing, `0002` CIP 0x04 for not-found) mean the simulator is
already partly ours. A native/libplctag disagreement against it must be checked against the real
press before blaming either side.
- **The same-width type check changes client-visible quality** (§5).
- **Behaviour libplctag hides that we now own:** connection keep-alive, sequence-count wrap, the
session-registration lifetime, and the TCP reconnect after an idle teardown by a NAT or firewall.
Each one gets a scheduler/connection test.