From 45b6062de52806727652b4ffc91e3e1f39600989 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 7 Oct 2026 14:35:14 -0400 Subject: [PATCH] =?UTF-8?q?docs(plans):=20native=20.NET=20EtherNet/IP+CIP+?= =?UTF-8?q?PCCC=20transport=20to=20replace=20libplctag=20under=20AbCip/AbL?= =?UTF-8?q?egacy=20=E2=80=94=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-10-07-native-ab-transport-design.md | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 docs/plans/2026-10-07-native-ab-transport-design.md diff --git a/docs/plans/2026-10-07-native-ab-transport-design.md b/docs/plans/2026-10-07-native-ab-transport-design.md new file mode 100644 index 00000000..23d180a7 --- /dev/null +++ b/docs/plans/2026-10-07-native-ab-transport-design.md @@ -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.