19 KiB
19 KiB
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)
- Native-binary pain — RID-specific packaging, container/ARM images, an unmanaged dependency in an otherwise all-.NET 10 server.
- Correctness and control — wrapper defects we cannot fix:
ElementSizereturnsnullafter a read (68cf3ae8), there is noConnectionSizeproperty (the value is plumbed and dropped), status codes are opaque, and libplctag never checks the CIP type the controller returns (e3d6b76ecould only add a width check). - Performance — one native tag handle per
(device, tag); no control over request packing or scheduling. - 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-njnxstays 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 asProgram:P.Arr[3].Member.5into symbolic, element and member segments.AbCipTagPathalready parses names;CipPathencodes 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.
- Connection size. It honours
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 firstInitializeAsyncand releases it onDispose. The last release sends Forward Close and UnRegisterSession.
3.2 Native adapters (in the driver projects)
NativeAbCipTagRuntime : IAbCipTagRuntime- Reads.
InitializeAsyncacquires the device lease and resolves the encoded path once.ReadAsyncissues Read Tag withElementCount. The reply's CIP type code + struct handle is kept with the buffer. - Decoding.
Decode*reads the managed buffer, little-endian. Strings use the LogixLEN(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 inResolveArrayStridestops being necessary.GetStatus()returnsAbStatus. - Writes.
WriteAsyncissues 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.
- Reads.
NativeAbCipTagEnumeratorreads controller-scope andProgram:Xlistings throughSymbolLister, and emits the concatenated entry bytes in the@tagslayout.NativeAbCipTemplateReaderemits 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 forplc5. - Arrays. Multi-element reads use the element count.
- Bits. Bit-in-word writes stay at the driver level (
WriteBitInWordAsync), as today.
- Addressing. Encodes
3.3 The switch
AbCipDriverOptions.TransportandAbLegacyDriverOptions.Transporttake valuesNative | Libplctag, are serialized as strings (JsonStringEnumConverter, per the driver-enum-serialization trap), and are absent ⇒ the current default.Ab*DriverFactoryExtensions.CreateInstancepicks 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
Transportdropdown in the AbCip and AbLegacy driver-config modals under the/rawtree. It needs live/runverification on docker-dev, since there is no bUnit. - Default sequence:
Libplctagat merge, flipped per §8, and the fallback stays selectable.
4. Data flow (a poll read)
- The driver poll calls
runtime.ReadAsync(ct)on N runtimes for one device. The concurrency window is unchanged. - Each runtime enqueues a Read Tag request on the device's
RequestScheduler. - 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). - 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 newTypeMismatch.- CIP general status mapping (native):
0x00⇒Ok0x04/0x05(path segment / destination unknown) ⇒NotFound0x06⇒Partial(continue fragmenting)0x08⇒Unsupported0x0F⇒NotAllowed0x13⇒TooSmall0x15⇒TooLarge0xFF⇒ 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) thate3d6b76edocuments 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.
- A mismatch ⇒
- 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.
ConnectionManagerenforces 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.
- Reconnect. A dropped connection is re-opened through the driver's existing
6. Packaging
- New project
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/and its test projecttests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/, both added toZB.MOM.WW.OtOpcUa.slnx. - While the fallback exists, both drivers keep the
libplctagpackage. Libplctag code is confined to theLibplctag*.csadapters. After phase 2 no other file hasusing libplctag, and a source-scan guard test enforces that. - On deletion (phase 6) the package, its
Directory.Packages.propspin, the adapters and theLibplctag*create-param members (LibplctagGateway;LibplctagPlcAttributeis renamedPlcAttribute) go.
7. Testing
- 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 inCipSymbolObjectWireCaptureTestsandCipTemplateObjectWireCaptureTests. - ⚠️ A capture from libplctag ⇄
ab_serverproves wire parity with libplctag, not correctness. The real-press captures are the independent reference (the #468 lesson: verify a fixture with the reference client).
- 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
- Integration suites run on both transports. The existing
AbCip.IntegrationTestsandAbLegacy.IntegrationTestsagainstab_server(controllogix / compactlogix / micro800 / guardlogix; slc500 / micrologix / plc5) are parameterized overTransport. A native run must be green on every test the libplctag run is green on. - 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.
- against
- AdminUI
/run. AuthorTransport: Nativeon a docker-dev AbCip driver pointed at theab_serverfixture, 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 rigcc876bdfverified 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,0and 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_serverpatches (0001symbol listing,0002CIP 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.