Files
lmxopcua/docs/plans/2026-10-07-native-ab-transport.md
T

183 KiB
Raw Blame History

Native AB Transport (replace libplctag) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Put a fully managed EtherNet/IP + CIP + PCCC client under the AbCip and AbLegacy drivers, selectable per driver against the existing libplctag path, and prove it with the integration suites, a differential harness, and a read-only real-press gate.

Architecture: Add a new pure-protocol project, ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport (encapsulation, CIP paths and services, connection manager, request scheduler with Multiple Service Packet coalescing, PCCC), referenced by both drivers. Each driver gains Native* adapters behind its existing runtime/enumerator/template-reader interfaces, plus a Transport option (Libplctag | Native, a string enum) that picks the factory set. libplctag is confined to the Libplctag*.cs adapters and translates its Status into a driver-owned AbStatus.

Tech Stack: .NET 10, C#, xUnit v3 + Shouldly, System.Net.Sockets, System.Buffers.Binary.BinaryPrimitives, the ab_server Docker fixtures on 10.100.0.35, Blazor (AdminUI form).

Spec: docs/plans/2026-10-07-native-ab-transport-design.md (commit 45b6062d). Executors read both documents. Where this plan and the spec disagree, the deviations listed under Global Constraints win.

Global Constraints

  • Wire format: everything is little-endian. Use BinaryPrimitives and the CipWriter/CipReader helpers from Task 1. No BitConverter, because it is endianness-dependent.
  • The decoders do not change. CipSymbolObjectDecoder and CipTemplateObjectDecoder stay byte-for-byte as they are. The native enumerator and template reader must emit the exact buffer layouts those decoders document (spec D8):
    • The symbol listing entry is u32 instanceId, u16 symbolType, u16 elementLength, u32×3 dims, u16 nameLen, name with no padding.
    • The template buffer is a 14-byte header u16 id, u32 defSizeWords, u32 instanceSize, u16 memberCount, u16 handle, followed by the raw Read Template bytes.
  • AbStatus is the only status vocabulary above the adapters. After Task 2/3 no file outside Libplctag*.cs may contain using libplctag. Task 11 enforces this with a source-scan guard.
  • IAbCipTagRuntime.GetStatus() / IAbLegacyTagRuntime.GetStatus() return (int)AbStatus under both transports. A zero value still means success, so every existing status != 0 check stays correct.
  • The Transport option is serialized as a string ("Native" / "Libplctag"). That means JsonStringEnumConverter in the AdminUI form, and the existing string DTO + ParseEnum in the factory extensions. An absent value means Libplctag until Task 22 flips it.
  • Press safety, for every native code path:
    • At most one Forward Open in flight per device, and a minimum 1 s between Forward Open attempts per device.
    • Exactly one CIP connection per (host, port, cipPath, plcAttribute).
    • Forward Close on the last lease release.
    • Real presses are touched only in Task 21, read-only, one press at a time.
  • One request in flight per connection. EncapsulationClient serializes requests and RequestScheduler has MaxInFlight = 1 hard-wired. Deviation from spec §3.1, which allowed a configurable MaxInFlight; per YAGNI there is no knob in v1.
  • One RequestScheduler per device connection, never per tag. It lives in the DeviceSessionPool entry beside the CipConnection and is exposed as ConnectionLease.Scheduler. Per-tag schedulers would make MSP coalescing impossible and race the connected sequence counter. That is review finding B1.
  • The connected sequence count is assigned inside EncapsulationClient's single-flight gate, never by a caller (B1).
  • Caller cancellation never faults the shared connection. A poll subscription's CTS is cancelled on every OPC UA unsubscribe (PollGroupEngine.cs:160). Faulting the socket on that would drop every tag on the press and force a fresh Forward Open. Instead:
    • Frames are written uncancellably (they are small and bounded by the socket).
    • Cancellation during the reply wait abandons only the waiter. The stale reply is skipped by context or sequence on the next exchange.
    • Only a timeout, an I/O error or a malformed frame faults the connection. That is review finding B2.
  • Routing is explicit: ConnectionOptions.Routing ∈ { Connected, UnconnectedSend, Direct }.
    • Connected = Forward Open + SendUnitData.
    • UnconnectedSend = SendRRData wrapping 0x52.
    • Direct = SendRRData carrying the CIP request bare, as libplctag does for SLC/MicroLogix/PLC-5 Execute PCCC even with a 1,0 path.
    • The captures decide each family's mode (B3).
  • Model tiers: mechanical ⇒ haiku, standard ⇒ sonnet, frontier ⇒ opus. Each task's metadata carries model.
  • Parallel execution: tasks in the same wave touch disjoint files and may run concurrently, each in its own git worktree off feat/native-ab-transport. They merge back serially in task-id order. Only Task 1 edits ZB.MOM.WW.OtOpcUa.slnx. No task edits Directory.Packages.props.
  • Build gate for every task: dotnet build ZB.MOM.WW.OtOpcUa.slnx with 0 warnings (TreatWarningsAsErrors is on for driver projects), and the task's test command green.
  • Naming: test classes follow <Subject>Tests, test methods use sentence-style names (A_refused_forward_open_is_not_retried_inside_the_minimum_interval), and test projects use xUnit v3 + Shouldly, matching ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj.

User decisions (already made):

  • "all of the above". All four motivations apply (native-binary pain, correctness/control, performance, policy).
  • "1, side-by-side behind a switch". There is a per-driver Transport switch and libplctag stays as the fallback.
  • "use recommended choices for all choices". The proof standard flips per evidence tier: AbCip reads, browse and templates must pass the real presses read-only; AbCip writes and all of AbLegacy are simulator-proven and recorded as a known gap. libplctag is deleted only after a bench controller exists. The stack lives in a new shared Driver.Ab.Transport project, with one connection per device, transparent MSP coalescing, a driver-owned AbStatus, reuse of the libplctag-shaped decoder buffers, and differential testing as the primary evidence.
  • "generate implementation plan to use subagents with appropriate models in parallel where safe. use fable subagent to review plans when done".

Out of plan (blocked, not a task): spec Phase 6, deleting libplctag. It is blocked until a bench CompactLogix or Micro800 (for writes) and an SLC or MicroLogix (for PCCC) are reachable. Record it in docs/drivers/AbCip.md "Known gaps" in Task 20.


Execution waves

Wave Tasks (parallel within a wave) Notes
0 T1 Creates the project, the slnx entries, the shared contracts and the empty gate-results doc.
1 T2, T3, T4, T6 T2/T3 are driver seams (disjoint projects); T4 captures golden frames from the docker host; T6 is the path encoders.
2 T5, T7, T8, T11 T5 needs T4 (capture parity); T7 needs T4+T6; T8 needs T4; T11 needs T2+T3. All disjoint folders.
3 T10 The scheduler (needs T7).
4 T9 Connection + pool (needs T5, T7, T10): the pool entry owns the scheduler.
5 T12, T13, T14 T12/T13 are disjoint AbCip files (need T2, T9); T14 is AbLegacy (needs T3, T8, T9).
6 T15, T16 AbCip switch (needs T12, T13) and AbLegacy switch (needs T14), in disjoint projects.
7 T17 Integration suites on both transports (needs T15, T16). It appends to the gate-results doc.
8 T18, T19 Differential harness (needs T17); AdminUI + /run (needs T17, since both append to the gate-results doc, so they are serialized behind T17; T18 and T19 append to different headed sections created by T1, which merge cleanly).
9 T20 Docs (needs T19's evidence and the T8/T14 verification status).
10 T21 → T22 Real-press gate (needs T18), then the default flip (needs T21, T17, T19, T20). Serial.

Dependency list (authoritative; mirrors .tasks.json blockedBy): T2,T3,T4,T6 ← T1 · T5 ← T1,T4 · T7 ← T4,T6 · T8 ← T4 · T11 ← T2,T3 · T10 ← T7 · T9 ← T5,T7,T10 · T12,T13 ← T2,T9 · T14 ← T3,T8,T9 · T15 ← T12,T13 · T16 ← T14 · T17 ← T15,T16 · T18 ← T17 · T19 ← T17 · T20 ← T19 · T21 ← T18 · T22 ← T21,T17,T19,T20

Model per task:

Tier Model Tasks
frontier opus T4, T9, T10, T12, T18, T21
standard sonnet T1, T2, T3, T5, T6, T7, T8, T13, T14, T15, T16, T17, T19, T20
mechanical haiku T11, T22

Task 1: Ab.Transport project skeleton + shared contracts

Goal: Create ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport and its test project, wired into the solution. It carries the shared vocabulary that every later task codes against: AbStatus, AbTransportException, AbStatusCodes, CipWriter/CipReader, CipReply, and ICipChannel.

Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatus.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbTransportException.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipWriter.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipReader.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipReply.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ICipChannel.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Wire/CipReplyTests.cs
  • Create: docs/plans/2026-10-07-native-ab-transport-gate-results.md (skeleton with five empty headed sections, ## ab_server suites (T17), ## Differential vs ab_server (T18), ## AdminUI /run (T19), ## Real press (T21) and ## Latency (T21), so later tasks append to separate sections and merge cleanly)
  • Modify: ZB.MOM.WW.OtOpcUa.slnx (add both projects next to the AbCip entries, lines 41-44 and 112-115)
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj and src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj (add a ProjectReference to Ab.Transport)

Acceptance Criteria:

  • dotnet build ZB.MOM.WW.OtOpcUa.slnx succeeds with 0 warnings and both new projects appear in the slnx.
  • AbStatus.Ok == 0, and every error member is negative.
  • AbStatusCodes.FromCip maps CIP general status as follows. It is aligned with the existing AbCipStatusMapper.MapCipGeneralStatus, so that existing mapping does not regress (review finding 13).
    • 0x00/0x0B → Ok
    • 0x04/0x05/0x16 → NotFound
    • 0x06 → Partial
    • 0x08 → Unsupported
    • 0x0A → OutOfBounds
    • 0x0C/0x10 → DeviceStateConflict
    • 0x0E/0x0F → NotAllowed
    • 0x13 → TooSmall
    • 0x15 → TooLarge
    • 0xFF + 0x2105 → OutOfBounds
    • 0xFF + 0x2107 → TypeMismatch
    • everything else, including 0x1E, → BadReply 0x1E (embedded service error) is only meaningful on an MSP envelope, and the scheduler (T10) handles it before calling FromCip.
  • AbStatusCodes.FromPccc maps STS 0x00→Ok, 0x10→Unsupported, 0x20→NotFound, 0x30→NotAllowed, 0x40/0x50→DeviceStateConflict, and 0xF0 maps via EXT STS (0x06/0x07→NotFound address problems, else BadReply).
  • CipReply.Parse round-trips a reply with 0, 1 and 2 extended-status words.
  • The driver projects reference Ab.Transport, and Ab.Transport references no OtOpcUa project.

Verify: dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests → all pass.

Steps:

  • Step 1: Create the projects. The library csproj mirrors the AbCip csproj property group (net10.0, nullable, TreatWarningsAsErrors, GenerateDocumentationFile, NoWarn CS1591). Its only package reference is Microsoft.Extensions.Logging.Abstractions. Check it is already pinned in Directory.Packages.props with grep -n Logging.Abstractions Directory.Packages.props. If it is absent, do not add it; the logger is optional and can be dropped from this project.
<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <TargetFramework>net10.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
        <LangVersion>latest</LangVersion>
        <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
        <GenerateDocumentationFile>true</GenerateDocumentationFile>
        <NoWarn>$(NoWarn);CS1591</NoWarn>
        <RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport</RootNamespace>
        <AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport</AssemblyName>
    </PropertyGroup>
    <ItemGroup>
        <PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
    </ItemGroup>
    <ItemGroup>
        <InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests"/>
    </ItemGroup>
</Project>

The test csproj is a copy of tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj with the RootNamespace and ProjectReference retargeted. Drop the OTOPCUA0001 NoWarn.

  • Step 2: Write the failing tests.
// tests/.../AbStatusCodesTests.cs
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests;

public sealed class AbStatusCodesTests
{
    [Theory]
    [InlineData((byte)0x00, AbStatus.Ok)]
    [InlineData((byte)0x04, AbStatus.NotFound)]
    [InlineData((byte)0x05, AbStatus.NotFound)]
    [InlineData((byte)0x16, AbStatus.NotFound)]
    [InlineData((byte)0x06, AbStatus.Partial)]
    [InlineData((byte)0x08, AbStatus.Unsupported)]
    [InlineData((byte)0x0F, AbStatus.NotAllowed)]
    [InlineData((byte)0x10, AbStatus.DeviceStateConflict)]
    [InlineData((byte)0x13, AbStatus.TooSmall)]
    [InlineData((byte)0x15, AbStatus.TooLarge)]
    [InlineData((byte)0x0B, AbStatus.Ok)]
    [InlineData((byte)0x0A, AbStatus.OutOfBounds)]
    [InlineData((byte)0x0C, AbStatus.DeviceStateConflict)]
    [InlineData((byte)0x0E, AbStatus.NotAllowed)]
    [InlineData((byte)0x1E, AbStatus.BadReply)]
    [InlineData((byte)0x01, AbStatus.BadReply)]
    public void Cip_general_status_maps(byte general, AbStatus expected) =>
        AbStatusCodes.FromCip(general, ReadOnlySpan<ushort>.Empty).ShouldBe(expected);

    [Theory]
    [InlineData((ushort)0x2105, AbStatus.OutOfBounds)]
    [InlineData((ushort)0x2107, AbStatus.TypeMismatch)]
    [InlineData((ushort)0x9999, AbStatus.BadReply)]
    public void General_status_FF_maps_by_extended_status(ushort ext, AbStatus expected) =>
        AbStatusCodes.FromCip(0xFF, [ext]).ShouldBe(expected);

    [Theory]
    [InlineData((byte)0x00, null, AbStatus.Ok)]
    [InlineData((byte)0x10, null, AbStatus.Unsupported)]
    [InlineData((byte)0x20, null, AbStatus.NotFound)]
    [InlineData((byte)0x30, null, AbStatus.NotAllowed)]
    [InlineData((byte)0x40, null, AbStatus.DeviceStateConflict)]
    [InlineData((byte)0xF0, (byte)0x06, AbStatus.NotFound)]
    [InlineData((byte)0xF0, (byte)0x07, AbStatus.NotFound)]
    [InlineData((byte)0xF0, (byte)0x0A, AbStatus.BadReply)]
    public void Pccc_status_maps(byte sts, byte? ext, AbStatus expected) =>
        AbStatusCodes.FromPccc(sts, ext).ShouldBe(expected);

    [Fact]
    public void Ok_is_zero_and_every_error_is_negative()
    {
        ((int)AbStatus.Ok).ShouldBe(0);
        foreach (var s in Enum.GetValues<AbStatus>().Where(s => s is not AbStatus.Ok and not AbStatus.Pending))
            ((int)s).ShouldBeLessThan(0);
    }
}
// tests/.../Wire/CipReplyTests.cs
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Wire;

public sealed class CipReplyTests
{
    [Fact]
    public void Parses_success_reply_with_data()
    {
        // 0xCC = reply to Read Tag 0x4C; reserved; status 0; ext size 0; data C4 00 2A 00 00 00
        var r = CipReply.Parse(new byte[] { 0xCC, 0x00, 0x00, 0x00, 0xC4, 0x00, 0x2A, 0x00, 0x00, 0x00 });
        r.Service.ShouldBe((byte)0x4C);
        r.GeneralStatus.ShouldBe((byte)0);
        r.ExtendedStatus.Length.ShouldBe(0);
        r.Data.ToArray().ShouldBe(new byte[] { 0xC4, 0x00, 0x2A, 0x00, 0x00, 0x00 });
    }

    [Fact]
    public void Parses_error_reply_with_two_extended_words()
    {
        var r = CipReply.Parse(new byte[] { 0xCC, 0x00, 0xFF, 0x02, 0x05, 0x21, 0x00, 0x00 });
        r.GeneralStatus.ShouldBe((byte)0xFF);
        r.ExtendedStatus.ToArray().ShouldBe(new ushort[] { 0x2105, 0x0000 });
        r.Data.Length.ShouldBe(0);
        r.Status.ShouldBe(AbStatus.OutOfBounds);
    }

    [Fact]
    public void Truncated_reply_throws_BadReply()
    {
        var ex = Should.Throw<AbTransportException>(() => CipReply.Parse(new byte[] { 0xCC, 0x00 }));
        ex.Status.ShouldBe(AbStatus.BadReply);
    }
}
  • Step 3: Run the tests and confirm they fail. Run dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests. Expected: build errors (types are not defined yet).

  • Step 4: Implement.

// AbStatus.cs
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;

/// <summary>Transport-neutral status shared by the libplctag and native AB transports.
/// Zero is success; every error is negative so existing <c>status != 0</c> checks stay correct.</summary>
public enum AbStatus
{
    Ok = 0,
    Pending = 1,
    Timeout = -1,
    NotFound = -2,
    NotAllowed = -3,
    OutOfBounds = -4,
    TooLarge = -5,
    TooSmall = -6,
    Unsupported = -7,
    BadConnection = -8,
    BadReply = -9,
    Partial = -10,
    Abort = -11,
    TypeMismatch = -12,
    DeviceStateConflict = -13,
}
// AbTransportException.cs
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;

/// <summary>Thrown by both AB transports. <see cref="Status"/> drives OPC UA status mapping and the
/// driver's "controller answered, don't throttle the device" classification (05/STAB-8).</summary>
public sealed class AbTransportException(AbStatus status, string message, Exception? inner = null)
    : Exception(message, inner)
{
    public AbStatus Status { get; } = status;
}
// AbStatusCodes.cs
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;

public static class AbStatusCodes
{
    public static AbStatus FromCip(byte general, ReadOnlySpan<ushort> extended) => general switch
    {
        0x00 or 0x0B => AbStatus.Ok,
        0x04 or 0x05 or 0x16 => AbStatus.NotFound,
        0x06 => AbStatus.Partial,
        0x08 => AbStatus.Unsupported,
        0x0A => AbStatus.OutOfBounds,
        0x0C or 0x10 => AbStatus.DeviceStateConflict,
        0x0E or 0x0F => AbStatus.NotAllowed,
        0x13 => AbStatus.TooSmall,
        0x15 => AbStatus.TooLarge,
        0xFF when extended.Length > 0 && extended[0] == 0x2105 => AbStatus.OutOfBounds,
        0xFF when extended.Length > 0 && extended[0] == 0x2107 => AbStatus.TypeMismatch,
        _ => AbStatus.BadReply,
    };

    public static AbStatus FromPccc(byte sts, byte? extSts) => sts switch
    {
        0x00 => AbStatus.Ok,
        0x10 => AbStatus.Unsupported,
        0x20 => AbStatus.NotFound,
        0x30 => AbStatus.NotAllowed,
        0x40 or 0x50 => AbStatus.DeviceStateConflict,
        0xF0 when extSts is 0x06 or 0x07 => AbStatus.NotFound,
        _ => AbStatus.BadReply,
    };
}
// Wire/CipWriter.cs
using System.Buffers.Binary;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;

/// <summary>Growable little-endian writer for CIP / EIP frames.</summary>
public sealed class CipWriter
{
    private byte[] _buf = new byte[256];
    public int Length { get; private set; }

    private Span<byte> Take(int n)
    {
        if (Length + n > _buf.Length) Array.Resize(ref _buf, Math.Max(_buf.Length * 2, Length + n));
        var s = _buf.AsSpan(Length, n);
        Length += n;
        return s;
    }

    public CipWriter U8(byte v) { Take(1)[0] = v; return this; }
    public CipWriter U16(ushort v) { BinaryPrimitives.WriteUInt16LittleEndian(Take(2), v); return this; }
    public CipWriter U32(uint v) { BinaryPrimitives.WriteUInt32LittleEndian(Take(4), v); return this; }
    public CipWriter U64(ulong v) { BinaryPrimitives.WriteUInt64LittleEndian(Take(8), v); return this; }
    public CipWriter Bytes(ReadOnlySpan<byte> v) { v.CopyTo(Take(v.Length)); return this; }
    public CipWriter PadToEven() { if ((Length & 1) == 1) U8(0); return this; }

    /// <summary>Overwrite a u16 already written at <paramref name="offset"/> (length back-patching).</summary>
    public void PatchU16(int offset, ushort v) => BinaryPrimitives.WriteUInt16LittleEndian(_buf.AsSpan(offset, 2), v);

    public byte[] ToArray() => _buf.AsSpan(0, Length).ToArray();
}
// Wire/CipReader.cs
using System.Buffers.Binary;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;

/// <summary>Bounds-checked little-endian reader. Any overrun throws AbTransportException(BadReply).</summary>
public ref struct CipReader(ReadOnlySpan<byte> data)
{
    private readonly ReadOnlySpan<byte> _data = data;
    public int Position { get; private set; }
    public readonly int Remaining => _data.Length - Position;

    private ReadOnlySpan<byte> Take(int n)
    {
        if (n < 0 || Position + n > _data.Length)
            throw new AbTransportException(AbStatus.BadReply, $"Reply truncated: need {n} bytes at {Position}, have {Remaining}.");
        var s = _data.Slice(Position, n);
        Position += n;
        return s;
    }

    public byte U8() => Take(1)[0];
    public ushort U16() => BinaryPrimitives.ReadUInt16LittleEndian(Take(2));
    public uint U32() => BinaryPrimitives.ReadUInt32LittleEndian(Take(4));
    public ulong U64() => BinaryPrimitives.ReadUInt64LittleEndian(Take(8));
    public ReadOnlySpan<byte> Bytes(int n) => Take(n);
    public ReadOnlySpan<byte> Rest() => Take(Remaining);
}
// Cip/CipReply.cs
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;

/// <summary>A parsed CIP message-router reply: service (request code, 0x80 bit stripped), general
/// status, extended status words, and the reply data.</summary>
public sealed record CipReply(byte Service, byte GeneralStatus, ushort[] ExtendedStatus, ReadOnlyMemory<byte> Data)
{
    public AbStatus Status => AbStatusCodes.FromCip(GeneralStatus, ExtendedStatus);

    public static CipReply Parse(ReadOnlyMemory<byte> raw)
    {
        var r = new CipReader(raw.Span);
        var service = (byte)(r.U8() & 0x7F);
        r.U8(); // reserved
        var general = r.U8();
        var extWords = r.U8();
        var ext = new ushort[extWords];
        for (var i = 0; i < extWords; i++) ext[i] = r.U16();
        var data = raw[r.Position..];
        return new CipReply(service, general, ext, data);
    }
}
// Cip/ICipChannel.cs
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;

/// <summary>A path to a CIP message router: connected (Forward Open) or unconnected (UCMM). Task 9
/// implements it; Task 10's scheduler and the native adapters consume it.</summary>
public interface ICipChannel
{
    /// <summary>Largest CIP request (service byte through end of data) one call may carry.</summary>
    int MaxRequestBytes { get; }

    /// <summary>Send one CIP request and return the raw CIP reply bytes (reply-service byte first).
    /// Throws <see cref="AbTransportException"/> on transport failure or timeout.</summary>
    Task<ReadOnlyMemory<byte>> SendAsync(ReadOnlyMemory<byte> cipRequest, CancellationToken cancellationToken);
}

Add the two <Project Path=...> lines to the slnx. Add <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj"/> to both driver csprojs.

  • Step 5: Run the tests. Run dotnet build ZB.MOM.WW.OtOpcUa.slnx && dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests. Expected: 0 warnings, all pass.

  • Step 6: Commit.

git add docs/plans/2026-10-07-native-ab-transport-gate-results.md src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests ZB.MOM.WW.OtOpcUa.slnx src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj
git commit -m "feat(ab-transport): project skeleton with AbStatus, CIP reply parsing and the ICipChannel seam"

Task 2: AbCip seam — decouple the driver from libplctag types

Goal: Change every AbCip file other than the Libplctag*.cs adapters so it speaks AbStatus / AbTransportException instead of libplctag's Status / LibPlcTagException. Behaviour stays identical.

Files:

  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs, LibplctagTagEnumerator.cs, LibplctagTemplateReader.cs (wrap libplctag calls; translate)
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs (replace MapLibplctagStatus(Status) / TryGetStatus(LibPlcTagException) with MapStatus(AbStatus), MapStatus(int), and MapException)
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs (IsReachableStatus(AbStatus), IsReachableException(AbTransportException); construct runtimes via an IAbCipTagFactory parameter that defaults to new LibplctagTagFactory())
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:1,1186-1191 (catch AbTransportException)
  • Modify (tests): tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs, AbCipDriverProbeTests.cs, AbCipDriverReadTests.cs, AbCipStatusMapperTests.cs, AbCipDriverCodeReviewRegressionTests.cs (line 326 uses libplctag.Status.ErrorBadConnection), AbCipUnknownTagReadTests.cs, AbCipLoggingTests.cs (line 91, same), FakeAbCipTag.cs. Run grep -rln "libplctag" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --include=*.cs and migrate every hit except LibplctagStatusTranslationTests.cs and the mapper parity oracle.
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs (its <see cref="AbCipStatusMapper.MapLibplctagStatus(int)"/> must become MapStatus(int). A dangling cref is CS1574, which TreatWarningsAsErrors turns into a build error.)
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs

Acceptance Criteria:

  • grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/*.cs lists only LibplctagTagRuntime.cs, LibplctagTagEnumerator.cs, LibplctagTemplateReader.cs and LibplctagStatusTranslation.cs.
  • LibplctagTagRuntime.GetStatus() returns (int)AbStatus, and every libplctag call that can throw LibPlcTagException rethrows AbTransportException with the translated status and the original as InnerException.
  • The translation table is the existing MapLibplctagStatus buckets re-expressed in AbStatus:
    • Ok→Ok, Pending→Pending, ErrorTimeout→Timeout
    • ErrorNotFound/ErrorNoMatch/ErrorBadDevice→NotFound
    • ErrorNotAllowed→NotAllowed
    • ErrorOutOfBounds→OutOfBounds, ErrorTooLarge→TooLarge, ErrorTooSmall→TooSmall
    • ErrorUnsupported/ErrorNotImplemented→Unsupported
    • ErrorPartial→Partial, ErrorAbort→Abort
    • ErrorBadReply→BadReply
    • every other member → BadConnection
  • AbCipStatusMapper.MapStatus(AbStatus) produces exactly the OPC UA codes the old mapper produced for the corresponding libplctag members. TypeMismatch maps to BadTypeMismatch and DeviceStateConflict to BadDeviceFailure. Partial and BadReply must still map to BadCommunicationError, as ErrorPartial / ErrorBadReply did.
  • The full existing AbCip.Tests suite is green, with the only changes being test fakes constructing AbTransportException(AbStatus.X, ...) instead of LibPlcTagException("ErrorX") and setting Status = (int)AbStatus.X.

Verify: dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests → all pass, then grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/*.cs → exactly the four Libplctag* files.

Steps:

  • Step 1: Write the translation test (failing).
// tests/.../LibplctagStatusTranslationTests.cs
using libplctag;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;

public sealed class LibplctagStatusTranslationTests
{
    [Theory]
    [InlineData(Status.Ok, AbStatus.Ok)]
    [InlineData(Status.Pending, AbStatus.Pending)]
    [InlineData(Status.ErrorTimeout, AbStatus.Timeout)]
    [InlineData(Status.ErrorNotFound, AbStatus.NotFound)]
    [InlineData(Status.ErrorNoMatch, AbStatus.NotFound)]
    [InlineData(Status.ErrorBadDevice, AbStatus.NotFound)]
    [InlineData(Status.ErrorNotAllowed, AbStatus.NotAllowed)]
    [InlineData(Status.ErrorOutOfBounds, AbStatus.OutOfBounds)]
    [InlineData(Status.ErrorTooLarge, AbStatus.TooLarge)]
    [InlineData(Status.ErrorTooSmall, AbStatus.TooSmall)]
    [InlineData(Status.ErrorUnsupported, AbStatus.Unsupported)]
    [InlineData(Status.ErrorNotImplemented, AbStatus.Unsupported)]
    [InlineData(Status.ErrorPartial, AbStatus.Partial)]
    [InlineData(Status.ErrorAbort, AbStatus.Abort)]
    [InlineData(Status.ErrorBadReply, AbStatus.BadReply)]
    [InlineData(Status.ErrorBadGateway, AbStatus.BadConnection)]
    [InlineData(Status.ErrorWinsock, AbStatus.BadConnection)]
    public void Translates(Status from, AbStatus to) => LibplctagStatusTranslation.ToAbStatus(from).ShouldBe(to);

    [Fact]
    public void Exception_message_is_recovered_as_the_enum_name()
    {
        LibplctagStatusTranslation.ToAbStatus(new LibPlcTagException("ErrorNotFound")).ShouldBe(AbStatus.NotFound);
        LibplctagStatusTranslation.ToAbStatus(new LibPlcTagException("gibberish")).ShouldBe(AbStatus.BadConnection);
    }
}

Add a matching parity test to AbCipStatusMapperTests: for every Status member, MapStatus(ToAbStatus(s)) must equal the OPC UA code the old MapLibplctagStatus(s) returned. Copy the old switch into the test as an expected-value table before deleting it from production. That table is the regression oracle.

  • Step 2: Run and confirm it fails. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter LibplctagStatusTranslation → compile error.

  • Step 3: Implement the translation and wrap the adapters.

// LibplctagStatusTranslation.cs
using libplctag;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;

/// <summary>The ONLY place libplctag's <see cref="Status"/> meets <see cref="AbStatus"/>.</summary>
internal static class LibplctagStatusTranslation
{
    public static AbStatus ToAbStatus(Status s) => s switch
    {
        Status.Ok => AbStatus.Ok,
        Status.Pending => AbStatus.Pending,
        Status.ErrorTimeout => AbStatus.Timeout,
        Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.NotFound,
        Status.ErrorNotAllowed => AbStatus.NotAllowed,
        Status.ErrorOutOfBounds => AbStatus.OutOfBounds,
        Status.ErrorTooLarge => AbStatus.TooLarge,
        Status.ErrorTooSmall => AbStatus.TooSmall,
        Status.ErrorUnsupported or Status.ErrorNotImplemented => AbStatus.Unsupported,
        Status.ErrorPartial => AbStatus.Partial,
        Status.ErrorAbort => AbStatus.Abort,
        Status.ErrorBadReply => AbStatus.BadReply,
        _ => AbStatus.BadConnection,
    };

    /// <summary>libplctag builds the exception from <c>Status.ToString()</c>, so the message is the enum name.</summary>
    public static AbStatus ToAbStatus(LibPlcTagException ex) =>
        Enum.TryParse<Status>((ex.Message ?? "").Trim(), ignoreCase: true, out var s) && Enum.IsDefined(s)
            ? ToAbStatus(s)
            : AbStatus.BadConnection;

    public static async Task Wrap(Func<Task> op)
    {
        try { await op().ConfigureAwait(false); }
        catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
    }
}

In LibplctagTagRuntime: InitializeAsync/ReadAsync/WriteAsync => LibplctagStatusTranslation.Wrap(() => _tag.XAsync(ct)), and GetStatus() => (int)LibplctagStatusTranslation.ToAbStatus(_tag.GetStatus()). Do the same in the enumerator's ReadListingAsync and in LibplctagTemplateReader.ReadAsync.

  • Step 4: Rewrite the mapper and probe and the driver catch.

AbCipStatusMapper (remove using libplctag):

public static uint MapStatus(int status) => MapStatus((AbStatus)status);

public static uint MapStatus(AbStatus status) => status switch
{
    AbStatus.Ok => Good,
    AbStatus.Pending => GoodMoreData,
    AbStatus.Timeout => BadTimeout,
    AbStatus.NotFound => BadNodeIdUnknown,
    AbStatus.NotAllowed => BadNotWritable,
    AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall => BadOutOfRange,
    AbStatus.Unsupported => BadNotSupported,
    AbStatus.TypeMismatch => BadTypeMismatch,
    AbStatus.DeviceStateConflict => BadDeviceFailure,
    _ => BadCommunicationError, // BadConnection, BadReply, Partial, Abort
};

public static uint MapException(Exception ex) =>
    ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError;

Rename every caller of MapLibplctagStatus to MapStatus; grep -rn MapLibplctagStatus src tests finds them all. Keep MapCipGeneralStatus unchanged.

AbCipDriverProbe:

internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
internal static bool IsReachableException(AbTransportException ex) => IsReachableStatus(ex.Status);

In the probe body, replace (Status)rt.GetStatus() with (AbStatus)rt.GetStatus(), statusCode == Status.Ok with == AbStatus.Ok, and catch (LibPlcTagException ex) with catch (AbTransportException ex). Replace new LibplctagTagRuntime(p) with tagFactory.Create(p), where tagFactory is a new optional parameter (IAbCipTagFactory? tagFactory = null ⇒ new LibplctagTagFactory()). Task 15 passes the transport-selected factory.

AbCipDriver.cs:1186:

if (ex is AbTransportException ate
    && ate.Status != AbStatus.Ok
    && AbCipDriverProbe.IsReachableStatus(ate.Status))
    device.Backoff.RecordSuccess();

Remove using libplctag; from AbCipDriver.cs and add using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;. Rename log template placeholders {LibplctagStatus} → {Status}, and change the messages from "libplctag status" to "transport status".

  • Step 5: Migrate the tests.

    • In FakeAbCipTag and every test that sets Status = / new LibPlcTagException("ErrorX"): use (int)AbStatus.X / new AbTransportException(AbStatus.X, "X").
    • Map the old names this way: ErrorNotFound→NotFound, ErrorTimeout→Timeout, ErrorBadConnection→BadConnection, ErrorNotAllowed→NotAllowed, ErrorOutOfBounds→OutOfBounds.
    • In AbCipDriverProbeTests line 174/188, change the theory to iterate AbStatus values. "something the wrapper has never said" becomes new AbTransportException(AbStatus.BadConnection, "..."), and the expectation is false.
  • Step 6: Run the suite. Run dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests. Expected: all pass, with the same test count as before plus the new translation tests.

  • Step 7: Commit.

git add -A src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests
git commit -m "refactor(abcip): confine libplctag to its adapters behind AbStatus/AbTransportException"

Task 3: AbLegacy seam — decouple the driver from libplctag types

Goal: Do the same as Task 2 for AbLegacy. Only LibplctagLegacyTagRuntime.cs and LibplctagLegacyStatusTranslation.cs may reference libplctag.

Files:

  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyStatusMapper.cs (MapStatus(AbStatus|int), MapException; keep MapPcccStatus)
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs (IsReachableStatus(AbStatus), catch AbTransportException, optional IAbLegacyTagFactory parameter defaulting to new LibplctagLegacyTagFactory())
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs (lines ~318-325: MapLibplctagStatus→MapStatus; log text "transport status"; any LibPlcTagException catch → AbTransportException)
  • Modify (tests): tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyEvictOnFailureTests.cs, AbLegacyReadWriteTests.cs (line 73), AbLegacyHostAndStatusTests.cs, AbLegacyBitRmwTests.cs (line 232), AbLegacyArrayTests.cs (line 115), FakeAbLegacyTag.cs. Run grep -rln "libplctag" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --include=*.cs and migrate every hit except the translation test and the parity oracle. Also grep src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy for cref=".*MapLibplctagStatus and fix any match (CS1574).
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyStatusTranslationTests.cs

Acceptance Criteria:

  • grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/*.cs lists exactly LibplctagLegacyTagRuntime.cs and LibplctagLegacyStatusTranslation.cs.
  • The translation table is identical to Task 2's, applied in the AbLegacy namespace. The table is duplicated, not shared, because each driver owns its own libplctag confinement and Task 22's eventual deletion removes both.
  • AbLegacyStatusMapper.MapStatus(AbStatus) gives exactly the outputs the old MapLibplctagStatus gave for the corresponding members, proven by a parity table copied from the old switch before deleting it.
  • The full AbLegacy.Tests suite is green.

Verify: dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests → all pass.

Steps:

  • Step 1: Write the failing tests. Copy Task 2's LibplctagStatusTranslationTests verbatim into tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyStatusTranslationTests.cs, and change the namespace to ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests and the class under test to LibplctagLegacyStatusTranslation. Add the mapper parity table to AbLegacyHostAndStatusTests, built from the old AbLegacyStatusMapper.MapLibplctagStatus switch at AbLegacyStatusMapper.cs:42-55.
  • Step 2: Run and confirm it fails. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter LibplctagLegacyStatusTranslation → compile error.
  • Step 3: Implement. Create LibplctagLegacyStatusTranslation with the same code as Task 2's LibplctagStatusTranslation (namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy). Then make these changes:
    • Wrap InitializeAsync/ReadAsync/WriteAsync in LibplctagLegacyTagRuntime with LibplctagLegacyStatusTranslation.Wrap(...).
    • GetStatus() => (int)LibplctagLegacyStatusTranslation.ToAbStatus(_tag.GetStatus()).
    • The mapper becomes:
public static uint MapStatus(int status) => MapStatus((AbStatus)status);
public static uint MapStatus(AbStatus status) => status switch
{
    AbStatus.Ok => Good,
    AbStatus.Pending => GoodMoreData,
    AbStatus.Timeout => BadTimeout,
    AbStatus.NotFound => BadNodeIdUnknown,
    AbStatus.NotAllowed => BadNotWritable,
    AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall => BadOutOfRange,
    AbStatus.Unsupported => BadNotSupported,
    AbStatus.TypeMismatch => BadTypeMismatch,
    AbStatus.DeviceStateConflict => BadDeviceFailure,
    _ => BadCommunicationError,
};
public static uint MapException(Exception ex) =>
    ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError;

If AbLegacyStatusMapper does not yet define BadTypeMismatch, add public const uint BadTypeMismatch = 0x80740000u; (the same value as AbCipStatusMapper).

  • In the probe: IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound, and every Status.* use moves to AbStatus.*.
  • Step 4: Migrate the tests. FakeAbLegacyTag.Status values and any LibPlcTagException move to (int)AbStatus.X / AbTransportException.
  • Step 5: Run the suite. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests → all pass.
  • Step 6: Commit.
git add -A src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests
git commit -m "refactor(ablegacy): confine libplctag to its adapter behind AbStatus/AbTransportException"

Task 4: Capture golden frames (libplctag ⇄ ab_server)

Goal: Capture, once, the exact request/reply bytes that libplctag 1.5.2 exchanges with ab_server for every operation the native stack must reproduce. Store them as hex fixtures that Tasks 7 and 8 assert parity against.

Files:

  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/README.md (how each capture was taken: commit, image tag, command)
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/*.hex (one file per exchange, as below)
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFile.cs (loader)
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFileTests.cs
  • Create: scripts/ab-capture/capture.sh (re-runnable recipe)

Acceptance Criteria:

  • Each .hex file holds alternating > (client→server) and < (server→client) lines of encapsulation frames. Each line is one full EIP frame (the 24-byte header plus data) as uppercase hex with no spaces. Lines starting with # are comments.
  • These captures exist and are non-empty:
    • logix-register-session.hex
    • logix-forward-open.hex (both a Large FO 0x5B and, if libplctag falls back or uses it, a 0x54)
    • logix-read-dint.hex, logix-read-dint-array16.hex, logix-read-string.hex, logix-read-bool.hex
    • logix-write-dint.hex, logix-write-string.hex
    • logix-read-unknown-tag.hex (CIP 0x04, via patch 0002)
    • logix-tags-listing.hex (@tags, via patch 0001)
    • logix-forward-close.hex
    • micro800-read-dint.hex (UCMM, no Forward Open)
    • slc500-read-n7.hex, slc500-write-n7.hex, slc500-read-f8.hex, micrologix-read-n7.hex, plc5-read-n7.hex
  • CaptureFileTests loads every .hex file, parses every frame header, and asserts that each frame's encapsulation length field equals its data length.
  • Captures/README.md records, for each file: the libplctag version (1.5.2), the ab_server image (otopcua-ab-server:libplctag-release), the compose profile, and the libplctag tag string used.
  • Captures/README.md has a "Routing observed" table, one row per family (controllogix, micro800, slc500, micrologix, plc5, plus logixpccc if reachable), with the columns: ForwardOpen yes/no · CIP request wrapped in Unconnected Send yes/no · route bytes · requestor-id bytes (PCCC). Tasks 9, 12 and 14 read their Routing choice from this table.

Verify: dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter CaptureFile → pass, and ls tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/*.hex | wc -l → ≥ 17.

Steps:

  • Step 1: Bring up each fixture on the docker host, one profile at a time (they all bind :44818):
ssh dohertj2@10.100.0.35 'cd /opt/otopcua-abcip && docker compose --profile controllogix up -d'

If /opt/otopcua-abcip is missing or stale, rsync first. Read the fixture's docs/drivers/AbServer-Test-Fixture.md for the exact path:

rsync -av tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/ dohertj2@10.100.0.35:/opt/otopcua-abcip/

For AbLegacy, the stack lives at ~/otopcua-ablegacy on the docker host, not /opt (infra/README.md:41,68). It is driven by AB_LEGACY_COMPOSE_PROFILE=<slc500|micrologix|plc5> docker compose up -d, and its source is tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/. Read that compose file for the published ports; do not assume :44818. For containers that publish other ports, or run concurrently, capture inside the container's network namespace with the netshoot form in Step 2.

  • Step 2: Capture server-side while driving libplctag from the Mac with the existing CLIs (otopcua-abcip-cli / otopcua-ablegacy-cli; run dotnet run --project src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli -- --help for the verbs):
ssh dohertj2@10.100.0.35 'sudo timeout 20 tcpdump -i any -U -w /tmp/cap.pcap tcp port 44818' &
dotnet run --project src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli -- read --gateway 10.100.0.35 --path 1,0 --tag TestDINT --type DInt
wait; scp dohertj2@10.100.0.35:/tmp/cap.pcap "$CAPDIR/logix-read-dint.pcap"   # CAPDIR = a fresh empty dir under the session scratchpad

If the CLI flags differ, use the CLI's own --help output. Do not invent flags. If sudo tcpdump is unavailable on the host, use docker run --rm --net=container:otopcua-ab-server-controllogix nicolaka/netshoot tcpdump -U -w - tcp port 44818 > cap.pcap instead.

  • Step 3: Convert each pcap into the .hex line format. Use scripts/ab-capture/capture.sh, which runs tshark -r X.pcap -T fields -e tcp.srcport -e tcp.payload -Y 'tcp.len>0', re-joins segments into EIP frames using the 24-byte header length field, and emits >/< by direction (srcport 44818 ⇒ <). Install tshark on the Mac with brew install wireshark if it is absent.

  • Step 4: Write the loader and its test.

// Captures/CaptureFile.cs
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;

internal sealed record CapturedFrame(bool FromClient, byte[] Bytes);

internal static class CaptureFile
{
    public static string Dir => Path.Combine(AppContext.BaseDirectory, "Captures");

    public static IReadOnlyList<CapturedFrame> Load(string name) =>
        File.ReadAllLines(Path.Combine(Dir, name))
            .Where(l => l.Length > 2 && l[0] is '>' or '<')
            .Select(l => new CapturedFrame(l[0] == '>', Convert.FromHexString(l[2..].Trim())))
            .ToList();

    /// <summary>The CIP payload of a SendRRData / SendUnitData frame (the last CPF item's data,
    /// minus the 2-byte sequence count for connected data).</summary>
    public static byte[] CipPayload(CapturedFrame f)
    {
        var b = f.Bytes;
        var cmd = BitConverter.ToUInt16(b, 0);          // test-only; fixtures are LE on a LE host
        var pos = 24 + 4 + 2;                           // header + interface handle + timeout
        var itemCount = BitConverter.ToUInt16(b, pos); pos += 2;
        byte[] last = [];
        for (var i = 0; i < itemCount; i++)
        {
            var type = BitConverter.ToUInt16(b, pos);
            var len = BitConverter.ToUInt16(b, pos + 2);
            last = b.AsSpan(pos + 4, len).ToArray();
            if (type == 0x00B1) last = last[2..];       // strip connected sequence count
            pos += 4 + len;
        }
        return cmd is 0x6F or 0x70 ? last : [];
    }
}

Add <None Include="Captures\*.hex" CopyToOutputDirectory="PreserveNewest" /> to the test csproj. CaptureFileTests iterates Directory.GetFiles(CaptureFile.Dir, "*.hex") and asserts, for every frame, BitConverter.ToUInt16(bytes, 2) == bytes.Length - 24.

  • Step 5: Run. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter CaptureFile → pass.

  • Step 6: Tear down the fixtures (docker compose down) and commit.

git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/*.csproj scripts/ab-capture
git commit -m "test(ab-transport): golden libplctag<->ab_server frame captures for Logix, Micro800 and PCCC"

⚠️ These captures prove parity with libplctag, not correctness. The independent reference is the real-press captures already in tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectRealControllerCaptureTests.cs and CipTemplateObjectWireCaptureTests.cs. Tasks 13 and 21 use those.


Task 5: EtherNet/IP encapsulation codec + EncapsulationClient

Goal: A TCP client that frames EIP encapsulation, registers a session, and sends SendRRData (unconnected) and SendUnitData (connected) requests with at most one outstanding request. It also includes a scriptable in-process fake server for tests.

Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationHeader.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationCodec.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationClient.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationCodecTests.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationClientTests.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeEipServer.cs

Acceptance Criteria:

  • EncapsulationCodec.RegisterSession() produces exactly 65 00 04 00 00000000 00000000 <8-byte context> 00000000 01 00 00 00.
  • EncapsulationCodec.SendRRData(session, ctx, cip, timeoutSec) produces: header with command 0x006F; data = interface handle 00000000, timeout u16, item count 02 00, Null Address item 00 00 00 00, then the Unconnected Data item B2 00 <len u16> followed by the cip bytes.
  • EncapsulationCodec.SendUnitData(session, connId, seq, cip) produces: command 0x0070; data = interface handle 00000000, timeout 00 00, item count 02 00, Connected Address item A1 00 04 00 <connId u32>, Connected Data item B1 00 <len+2 u16> <seq u16> followed by the cip bytes.
  • EncapsulationCodec.ParseReply returns the header and the CPF item payload. For a connected reply it strips the sequence count. It throws AbTransportException(BadReply) when encapsulation status ≠ 0, the length is wrong, or the expected item is missing.
  • EncapsulationClient:
    • Lifecycle. It connects, registers (and stores the session handle from the reply), and UnRegisterSessions on DisposeAsync.
    • Single flight. It serializes requests with a SemaphoreSlim(1,1).
    • Connected sequence (B1). SendUnitDataAsync(otConnectionId, cip, timeout, ct) takes no sequence argument. The client owns the connected sequence counter and increments it inside the gate, wrapping 65535 → 1.
    • Matching replies.
      • Unconnected replies match on the 8-byte sender context.
      • Connected (0x70) replies match on the echoed sequence count.
      • Any frame that doesn't match (an earlier abandoned request's late reply) is skipped and the client keeps reading. It is not treated as an error.
    • Caller cancellation (B2).
      • The frame write uses CancellationToken.None, so a write is never half-sent.
      • If the caller's token is cancelled during the reply wait, the call throws OperationCanceledException. The connection is not faulted: IsConnected stays true, the gate is released, and the late reply is skipped by the next exchange.
    • Faults.
      • The timeout elapsing (no reply within timeout) throws AbTransportException(Timeout) and faults the connection by closing the socket. A target that has stopped answering cannot be trusted.
      • A socket or I/O error throws AbTransportException(BadConnection) and faults the connection.
      • IsConnected turns false after any fault.
  • Parity: the bytes RegisterSession sends, apart from the sender context, equal the first > frame of Captures/logix-register-session.hex.
  • FakeEipServer accepts a script of Func<byte[] request, byte[]? reply> steps, records every request it receives, and can be told to stall (to test timeouts) or drop the connection.

Verify: dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~Eip" → all pass.

Steps:

  • Step 1: Write the codec golden tests (failing).
// Eip/EncapsulationCodecTests.cs
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Eip;

public sealed class EncapsulationCodecTests
{
    private const ulong Ctx = 0x0807060504030201;

    [Fact]
    public void RegisterSession_frame_is_byte_exact() =>
        Convert.ToHexString(EncapsulationCodec.RegisterSession(Ctx)).ShouldBe(
            "6500" + "0400" + "00000000" + "00000000" + "0102030405060708" + "00000000" + "0100" + "0000");

    [Fact]
    public void SendRRData_wraps_cip_in_null_address_and_unconnected_data_items()
    {
        var frame = EncapsulationCodec.SendRRData(0x11223344, Ctx, new byte[] { 0x4C, 0x02 }, timeoutSeconds: 10);
        Convert.ToHexString(frame).ShouldBe(
            "6F00" + "1200" + "44332211" + "00000000" + "0102030405060708" + "00000000" +
            "00000000" + "0A00" + "0200" + "00000000" + "B2000200" + "4C02");
    }

    [Fact]
    public void SendUnitData_wraps_cip_with_connection_id_and_sequence()
    {
        var frame = EncapsulationCodec.SendUnitData(0x11223344, Ctx, connectionId: 0xAABBCCDD, sequence: 7, new byte[] { 0x4C, 0x02 });
        Convert.ToHexString(frame).ShouldBe(
            "7000" + "1800" + "44332211" + "00000000" + "0102030405060708" + "00000000" +
            "00000000" + "0000" + "0200" + "A1000400" + "DDCCBBAA" + "B1000400" + "0700" + "4C02");
    }

    [Fact]
    public void RegisterSession_matches_libplctag_capture_except_context()
    {
        var captured = CaptureFile.Load("logix-register-session.hex").First(f => f.FromClient).Bytes;
        var ours = EncapsulationCodec.RegisterSession(Ctx);
        ours.AsSpan(0, 12).ToArray().ShouldBe(captured.AsSpan(0, 12).ToArray());   // cmd,len,session,status
        ours.AsSpan(20).ToArray().ShouldBe(captured.AsSpan(20).ToArray());         // options + data
    }

    [Fact]
    public void Nonzero_encapsulation_status_is_BadReply()
    {
        var reply = Convert.FromHexString("6F00" + "0000" + "44332211" + "01000000" + "0102030405060708" + "00000000");
        Should.Throw<AbTransportException>(() => EncapsulationCodec.ParseReply(reply, expectConnected: false))
            .Status.ShouldBe(AbStatus.BadReply);
    }
}
  • Step 2: Run and confirm it fails. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter FullyQualifiedName~Eip → compile error.

  • Step 3: Implement the codec.

// Eip/EncapsulationHeader.cs
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;

public static class EncapCommand
{
    public const ushort ListIdentity = 0x0063, RegisterSession = 0x0065, UnRegisterSession = 0x0066,
        SendRRData = 0x006F, SendUnitData = 0x0070;
}

public readonly record struct EncapsulationHeader(
    ushort Command, ushort Length, uint SessionHandle, uint Status, ulong SenderContext, uint Options)
{
    public const int Size = 24;
}
// Eip/EncapsulationCodec.cs
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;

public static class EncapsulationCodec
{
    private static CipWriter Header(ushort cmd, uint session, ulong ctx) =>
        new CipWriter().U16(cmd).U16(0).U32(session).U32(0).U64(ctx).U32(0);

    private static byte[] Finish(CipWriter w)
    {
        w.PatchU16(2, (ushort)(w.Length - EncapsulationHeader.Size));
        return w.ToArray();
    }

    public static byte[] RegisterSession(ulong ctx) =>
        Finish(Header(EncapCommand.RegisterSession, 0, ctx).U16(1).U16(0));

    public static byte[] UnRegisterSession(uint session, ulong ctx) =>
        Finish(Header(EncapCommand.UnRegisterSession, session, ctx));

    public static byte[] SendRRData(uint session, ulong ctx, ReadOnlySpan<byte> cip, ushort timeoutSeconds) =>
        Finish(Header(EncapCommand.SendRRData, session, ctx)
            .U32(0).U16(timeoutSeconds).U16(2)
            .U16(0x0000).U16(0)
            .U16(0x00B2).U16((ushort)cip.Length).Bytes(cip));

    public static byte[] SendUnitData(uint session, ulong ctx, uint connectionId, ushort sequence, ReadOnlySpan<byte> cip) =>
        Finish(Header(EncapCommand.SendUnitData, session, ctx)
            .U32(0).U16(0).U16(2)
            .U16(0x00A1).U16(4).U32(connectionId)
            .U16(0x00B1).U16((ushort)(cip.Length + 2)).U16(sequence).Bytes(cip));

    public static EncapsulationHeader ReadHeader(ReadOnlySpan<byte> b)
    {
        var r = new CipReader(b);
        return new EncapsulationHeader(r.U16(), r.U16(), r.U32(), r.U32(), r.U64(), r.U32());
    }

    /// <summary>Parse a reply frame. Returns the header plus the CIP payload of the data item
    /// (0x00B2 unconnected / 0x00B1 connected, sequence count stripped).</summary>
    public static (EncapsulationHeader Header, ReadOnlyMemory<byte> Cip, ushort Sequence) ParseReply(
        ReadOnlyMemory<byte> frame, bool expectConnected)
    {
        var h = ReadHeader(frame.Span);
        if (h.Status != 0)
            throw new AbTransportException(AbStatus.BadReply, $"Encapsulation status 0x{h.Status:X8} on command 0x{h.Command:X4}.");
        if (h.Length != frame.Length - EncapsulationHeader.Size)
            throw new AbTransportException(AbStatus.BadReply, "Encapsulation length mismatch.");
        if (h.Command is EncapCommand.RegisterSession or EncapCommand.UnRegisterSession)
            return (h, ReadOnlyMemory<byte>.Empty, 0);

        var r = new CipReader(frame.Span[EncapsulationHeader.Size..]);
        r.U32(); r.U16();                              // interface handle, timeout
        var items = r.U16();
        var offset = EncapsulationHeader.Size + 8;
        for (var i = 0; i < items; i++)
        {
            var type = r.U16();
            var len = r.U16();
            offset += 4;
            if (type == (expectConnected ? 0x00B1 : 0x00B2))
            {
                if (!expectConnected) return (h, frame.Slice(offset, len), 0);
                var seq = (ushort)(frame.Span[offset] | (frame.Span[offset + 1] << 8));
                return (h, frame.Slice(offset + 2, len - 2), seq);
            }
            r.Bytes(len);
            offset += len;
        }
        throw new AbTransportException(AbStatus.BadReply, "Reply carried no data item.");
    }
}
  • Step 4: Write the client tests against FakeEipServer (failing).

FakeEipServer sketch (complete it):

// Fakes/FakeEipServer.cs
using System.Net;
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;

/// <summary>Loopback EIP endpoint. Each received frame is passed to Handler; a non-null return is
/// written back. Answers RegisterSession / UnRegisterSession automatically (session 0x11223344).</summary>
internal sealed class FakeEipServer : IAsyncDisposable
{
    private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
    private readonly CancellationTokenSource _cts = new();
    public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
    public List<byte[]> Received { get; } = [];
    public int Connections;
    public Func<byte[], byte[]?> Handler { get; set; } = _ => null;
    public bool Stall { get; set; }

    public FakeEipServer() { _listener.Start(); _ = AcceptLoop(); }

    private async Task AcceptLoop()
    {
        while (!_cts.IsCancellationRequested)
        {
            TcpClient c;
            try { c = await _listener.AcceptTcpClientAsync(_cts.Token); } catch { return; }
            Interlocked.Increment(ref Connections);
            _ = Serve(c);
        }
    }

    private async Task Serve(TcpClient c)
    {
        using var _ = c;
        var s = c.GetStream();
        var head = new byte[24];
        while (!_cts.IsCancellationRequested)
        {
            if (!await ReadExact(s, head)) return;
            var len = head[2] | (head[3] << 8);
            var frame = new byte[24 + len];
            head.CopyTo(frame, 0);
            if (!await ReadExact(s, frame.AsMemory(24))) return;
            lock (Received) Received.Add(frame);
            var cmd = head[0] | (head[1] << 8);
            byte[]? reply = cmd switch
            {
                0x65 => RegisterReply(frame),
                0x66 => null,
                _ => Stall ? null : Handler(frame),
            };
            if (reply is not null) await s.WriteAsync(reply, _cts.Token);
        }
    }

    private static byte[] RegisterReply(byte[] req)
    {
        var r = (byte[])req.Clone();
        r[4] = 0x44; r[5] = 0x33; r[6] = 0x22; r[7] = 0x11;
        return r;
    }

    private async Task<bool> ReadExact(NetworkStream s, Memory<byte> buf)
    {
        var got = 0;
        while (got < buf.Length)
        {
            int n;
            try { n = await s.ReadAsync(buf[got..], _cts.Token); } catch { return false; }
            if (n == 0) return false;
            got += n;
        }
        return true;
    }

    /// <summary>Build a SendRRData reply echoing the request's session + context with the given CIP reply.</summary>
    public static byte[] RRReply(byte[] request, byte[] cipReply)
    {
        var session = BitConverter.ToUInt32(request, 4);
        var ctx = BitConverter.ToUInt64(request, 12);
        return ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip.EncapsulationCodec.SendRRData(session, ctx, cipReply, 0);
    }

    public async ValueTask DisposeAsync() { _cts.Cancel(); _listener.Stop(); await Task.CompletedTask; }
}

Client tests to write:

  • Registers_and_stores_session_handle: after ConnectAsync, SessionHandle == 0x11223344.

  • SendRRData_returns_cip_payload: the handler replies RRReply(req, [0xCC,0,0,0,0xC4,0,1,0,0,0]), and the result equals those 10 bytes.

  • Concurrent_sends_are_serialized: fire 5 SendRRDataAsync calls concurrently, with a handler that sleeps 20 ms and records the arrival time. Assert the received frames never overlap; each request arrives only after the previous reply was sent.

  • Stalled_reply_times_out_and_closes_the_socket: set Stall = true and use a 200 ms timeout. Assert AbTransportException(Timeout) and IsConnected == false.

  • Caller_cancellation_does_not_fault_the_connection (B2):

    • Have the handler delay its reply by 300 ms, and cancel the caller's token at 50 ms.
    • Assert OperationCanceledException and IsConnected == true.
    • Then send a second request whose handler answers immediately. Assert it returns its own payload: the stale reply arrives first and is skipped.
  • Connected_sequence_is_assigned_inside_the_gate: fire 20 concurrent SendUnitDataAsync calls, with a handler that echoes the sequence. Assert the sequences seen on the wire are strictly increasing in arrival order, with no duplicates.

  • Reply_with_foreign_context_is_skipped: the handler first writes a reply carrying a different context, then the correct one. The call returns the correct payload.

  • Dispose_sends_UnRegisterSession: after DisposeAsync, Received contains a 0x66 frame.

  • Step 5: Implement EncapsulationClient.

// Eip/EncapsulationClient.cs
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;

public sealed class EncapsulationClient : IAsyncDisposable
{
    private readonly TcpClient _tcp;
    private readonly NetworkStream _stream;
    private readonly SemaphoreSlim _gate = new(1, 1);
    private long _ctx;
    private ushort _seq;             // connected sequence count; only touched under _gate
    private volatile bool _faulted;

    public uint SessionHandle { get; private set; }
    public bool IsConnected => !_faulted && _tcp.Connected;

    private EncapsulationClient(TcpClient tcp) { _tcp = tcp; _stream = tcp.GetStream(); }

    public static async Task<EncapsulationClient> ConnectAsync(string host, int port, TimeSpan timeout, CancellationToken ct)
    {
        var tcp = new TcpClient { NoDelay = true };
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
        cts.CancelAfter(timeout);
        try { await tcp.ConnectAsync(host, port, cts.Token).ConfigureAwait(false); }
        catch (OperationCanceledException) when (!ct.IsCancellationRequested)
        { tcp.Dispose(); throw new AbTransportException(AbStatus.Timeout, $"TCP connect to {host}:{port} timed out."); }
        catch (SocketException ex)
        { tcp.Dispose(); throw new AbTransportException(AbStatus.BadConnection, $"TCP connect to {host}:{port} failed: {ex.SocketErrorCode}", ex); }

        var c = new EncapsulationClient(tcp);
        var reply = await c.ExchangeAsync(ctx => EncapsulationCodec.RegisterSession(ctx), timeout, ct).ConfigureAwait(false);
        c.SessionHandle = EncapsulationCodec.ReadHeader(reply.Span).SessionHandle;
        return c;
    }

    public async Task<ReadOnlyMemory<byte>> SendRRDataAsync(ReadOnlyMemory<byte> cip, TimeSpan timeout, CancellationToken ct)
    {
        var secs = (ushort)Math.Clamp((int)Math.Ceiling(timeout.TotalSeconds), 1, ushort.MaxValue);
        var frame = await ExchangeAsync(ctx => EncapsulationCodec.SendRRData(SessionHandle, ctx, cip.Span, secs), timeout, ct).ConfigureAwait(false);
        return EncapsulationCodec.ParseReply(frame, expectConnected: false).Cip;
    }

    public async Task<ReadOnlyMemory<byte>> SendUnitDataAsync(uint otConnectionId, ReadOnlyMemory<byte> cip,
        TimeSpan timeout, CancellationToken ct)
    {
        // The sequence is chosen inside ExchangeAsync's gate (build runs under it), so concurrent
        // callers can never put sequences on the wire out of order (review B1).
        var frame = await ExchangeAsync(ctx =>
        {
            _seq = _seq == ushort.MaxValue ? (ushort)1 : (ushort)(_seq + 1);
            return EncapsulationCodec.SendUnitData(SessionHandle, ctx, otConnectionId, _seq, cip.Span);
        }, timeout, ct).ConfigureAwait(false);
        return EncapsulationCodec.ParseReply(frame, expectConnected: true).Cip;
    }

    /// <summary>Write one frame, then read frames until the matching reply arrives. Unconnected
    /// replies match on sender context; connected (0x70) replies match on the echoed sequence.
    /// Non-matching frames are late replies to abandoned requests and are skipped.
    /// Caller cancellation abandons the wait WITHOUT faulting the stream (review B2); only the
    /// timeout, an I/O error or a malformed frame faults it.</summary>
    private async Task<ReadOnlyMemory<byte>> ExchangeAsync(Func<ulong, byte[]> build, TimeSpan timeout, CancellationToken ct)
    {
        if (_faulted) throw new AbTransportException(AbStatus.BadConnection, "Session is faulted; reconnect.");
        await _gate.WaitAsync(ct).ConfigureAwait(false);
        using var timeoutCts = new CancellationTokenSource(timeout);
        using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
        try
        {
            var ctx = (ulong)Interlocked.Increment(ref _ctx);
            var request = build(ctx);
            var cmd = (ushort)(request[0] | (request[1] << 8));
            var expectSeq = cmd == EncapCommand.SendUnitData ? _seq : (ushort)0; // set by build() above, under the gate
            await _stream.WriteAsync(request, CancellationToken.None).ConfigureAwait(false);
            while (true)
            {
                var frame = await ReadFrameAsync(linked.Token).ConfigureAwait(false);
                var h = EncapsulationCodec.ReadHeader(frame.Span);
                if (h.Command != cmd) continue;
                if (cmd == EncapCommand.SendUnitData)
                {
                    if (EncapsulationCodec.ParseReply(frame, expectConnected: true).Sequence != expectSeq) continue;
                }
                else if (h.SenderContext != ctx) continue;
                return frame;
            }
        }
        catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)
        { Fault(); throw new AbTransportException(AbStatus.Timeout, $"No reply within {timeout.TotalMilliseconds:F0} ms."); }
        catch (OperationCanceledException) { throw; } // caller cancelled: keep the stream; the late reply is skipped later
        catch (AbTransportException) { Fault(); throw; }
        catch (Exception ex) when (ex is IOException or SocketException or ObjectDisposedException or EndOfStreamException)
        { Fault(); throw new AbTransportException(AbStatus.BadConnection, ex.Message, ex); }
        finally { _gate.Release(); }
    }

    private async Task<ReadOnlyMemory<byte>> ReadFrameAsync(CancellationToken ct)
    {
        var head = new byte[EncapsulationHeader.Size];
        await _stream.ReadExactlyAsync(head, ct).ConfigureAwait(false);
        var len = head[2] | (head[3] << 8);
        var frame = new byte[EncapsulationHeader.Size + len];
        head.CopyTo(frame, 0);
        await _stream.ReadExactlyAsync(frame.AsMemory(EncapsulationHeader.Size), ct).ConfigureAwait(false);
        return frame;
    }

    private void Fault() { _faulted = true; try { _tcp.Close(); } catch { /* already closed */ } }

    public async ValueTask DisposeAsync()
    {
        if (!_faulted && _tcp.Connected)
        {
            try
            {
                var bytes = EncapsulationCodec.UnRegisterSession(SessionHandle, 0);
                using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
                await _stream.WriteAsync(bytes, cts.Token).ConfigureAwait(false);
            }
            catch { /* best effort */ }
        }
        _tcp.Dispose();
        _gate.Dispose();
    }
}

Note: a caller cancellation leaves the stream intact. The abandoned reply is skipped when it arrives. Only a timeout or an I/O fault closes the socket, and CipConnection (Task 9) then reconnects through the Forward Open throttle. This distinction is press safety: unsubscribing an OPC UA monitored item must not cost the press a new Forward Open.

  • Step 6: Run. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter FullyQualifiedName~Eip → all pass.
  • Step 7: Commit. git add -A src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes && git commit -m "feat(ab-transport): EtherNet/IP encapsulation codec and single-flight session client"

Task 6: CIP path encoding + Logix type codes

Goal: Pure encoders for (a) the route from a CIP path string such as 1,0 and (b) the request path for a Logix symbolic tag name. Also provide the Logix atomic type-code table that the native runtime uses for writes and for the type check.

Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTypeCode.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipPathTests.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTypeCodeTests.cs

Acceptance Criteria:

  • CipPath.EncodeRoute("1,0") → 01 00. EncodeRoute("") → empty. EncodeRoute("1,0,2,10.0.0.5") → 01 00 12 08 31 30 2E 30 2E 30 2E 35. That is port 2 with the extended-link-address flag 0x10 | 2 = 0x12, length 8, the ASCII text, and no pad because the length is even. A 9-character address gets one pad byte. Port numbers ≥ 15 use the extended-port form 0x0F, u16 port.
  • CipPath.EncodeSymbolic("TestDINT") → 91 08 54 65 73 74 44 49 4E 54. An odd-length name gets a pad byte after the name.
  • EncodeSymbolic("Program:Main.Arr[3].Val") → 91 0C "Program:Main", then 91 03 "Arr" 00, then element 28 03, then 91 03 "Val" 00.
  • Element segments use 28 xx for indices < 256, 29 00 lo hi for indices < 65536, and 2A 00 b0 b1 b2 b3 above that. A multi-dimensional [1,2] emits two element segments.
  • A trailing purely numeric member (Tag.5, the bit selector) is not encoded. EncodeSymbolic throws ArgumentException, and CipPath.SplitBitSelector("Tag.5") returns ("Tag", 5). Bit selection is the runtime's job (Task 12).
  • CipPath.EncodeClassInstance(cls, inst) uses 8-bit logical segments (20 cc 24 ii) when they fit, otherwise 16-bit (21 00 cc cc / 25 00 ii ii).
  • CipPath.WithPathSize(segments) prefixes the size in 16-bit words.
  • LogixTypeCode has these constants: Bool=0xC1, SInt=0xC2, Int=0xC3, DInt=0xC4, LInt=0xC5, USInt=0xC6, UInt=0xC7, UDInt=0xC8, ULInt=0xC9, Real=0xCA, LReal=0xCB, Struct=0x02A0, BoolArray=0xD3. ByteWidth(code) returns 1/1/2/4/8/1/2/4/8/4/8/4.

Verify: dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~CipPath|FullyQualifiedName~LogixTypeCode" → pass.

Steps:

  • Step 1: Write the failing tests for every acceptance-criteria row above. Each one is an [InlineData(input, expectedHex)] theory asserting Convert.ToHexString(CipPath.EncodeX(input)).ShouldBe(expectedHex). For example:
[Theory]
[InlineData("TestDINT", "9108546573744449 4E54")]
[InlineData("Program:Main.Arr[3].Val", "910C50726F6772616D3A4D61696E" + "910341727200" + "2803" + "910356616C00")]
[InlineData("Big[300]", "910342696700" + "29002C01")]
[InlineData("Grid[1,2]", "910447726964" + "2801" + "2802")]
public void Encodes_symbolic(string name, string hex) =>
    Convert.ToHexString(CipPath.EncodeSymbolic(name)).ShouldBe(hex.Replace(" ", ""));
  • Step 2: Run and confirm it fails. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~CipPath|FullyQualifiedName~LogixTypeCode" → compile error.

  • Step 3: Implement.

// Cip/CipPath.cs
using System.Globalization;
using System.Text;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;

public static class CipPath
{
    /// <summary>Route path from "port,link[,port,link…]". Links that are not a byte value are IP
    /// addresses, encoded as extended link-address port segments.</summary>
    public static byte[] EncodeRoute(string cipPath)
    {
        var w = new CipWriter();
        if (string.IsNullOrWhiteSpace(cipPath)) return [];
        var parts = cipPath.Split(',', StringSplitOptions.TrimEntries);
        if (parts.Length % 2 != 0) throw new ArgumentException($"CIP path '{cipPath}' must be port,link pairs.");
        for (var i = 0; i < parts.Length; i += 2)
        {
            var port = int.Parse(parts[i], CultureInfo.InvariantCulture);
            var link = parts[i + 1];
            var isByte = byte.TryParse(link, NumberStyles.None, CultureInfo.InvariantCulture, out var linkByte);
            if (port < 15)
            {
                if (isByte) w.U8((byte)port).U8(linkByte);
                else { var a = Encoding.ASCII.GetBytes(link); w.U8((byte)(0x10 | port)).U8((byte)a.Length).Bytes(a).PadToEven(); }
            }
            else
            {
                if (isByte) w.U8(0x0F).U16((ushort)port).U8(linkByte);
                else { var a = Encoding.ASCII.GetBytes(link); w.U8(0x1F).U8((byte)a.Length).U16((ushort)port).Bytes(a).PadToEven(); }
            }
        }
        return w.ToArray();
    }

    public static (string Name, int? Bit) SplitBitSelector(string tagName)
    {
        var dot = tagName.LastIndexOf('.');
        if (dot > 0 && dot < tagName.Length - 1 && tagName[(dot + 1)..].All(char.IsAsciiDigit))
            return (tagName[..dot], int.Parse(tagName[(dot + 1)..], CultureInfo.InvariantCulture));
        return (tagName, null);
    }

    public static byte[] EncodeSymbolic(string tagName)
    {
        if (SplitBitSelector(tagName).Bit is not null)
            throw new ArgumentException($"'{tagName}' ends in a bit selector; strip it with SplitBitSelector first.");
        var w = new CipWriter();
        foreach (var part in SplitMembers(tagName))
        {
            var bracket = part.IndexOf('[');
            var name = bracket < 0 ? part : part[..bracket];
            var a = Encoding.ASCII.GetBytes(name);
            w.U8(0x91).U8((byte)a.Length).Bytes(a).PadToEven();
            if (bracket >= 0)
            {
                foreach (var idxText in part[(bracket + 1)..part.IndexOf(']')].Split(',', StringSplitOptions.TrimEntries))
                {
                    var idx = uint.Parse(idxText, CultureInfo.InvariantCulture);
                    if (idx < 0x100) w.U8(0x28).U8((byte)idx);
                    else if (idx < 0x10000) w.U8(0x29).U8(0).U16((ushort)idx);
                    else w.U8(0x2A).U8(0).U32(idx);
                }
            }
        }
        return w.ToArray();
    }

    /// <summary>Split on '.' outside brackets, keeping "Program:X" whole.</summary>
    private static IEnumerable<string> SplitMembers(string name)
    {
        var depth = 0; var start = 0;
        for (var i = 0; i < name.Length; i++)
        {
            if (name[i] == '[') depth++;
            else if (name[i] == ']') depth--;
            else if (name[i] == '.' && depth == 0) { yield return name[start..i]; start = i + 1; }
        }
        yield return name[start..];
    }

    public static byte[] EncodeClassInstance(ushort cls, uint instance)
    {
        var w = new CipWriter();
        if (cls < 0x100) w.U8(0x20).U8((byte)cls); else w.U8(0x21).U8(0).U16(cls);
        if (instance < 0x100) w.U8(0x24).U8((byte)instance);
        else if (instance < 0x10000) w.U8(0x25).U8(0).U16((ushort)instance);
        else w.U8(0x26).U8(0).U32(instance);
        return w.ToArray();
    }

    /// <summary>Message Router (class 2, instance 1).</summary>
    public static readonly byte[] MessageRouter = [0x20, 0x02, 0x24, 0x01];

    /// <summary>Connection Manager (class 6, instance 1).</summary>
    public static readonly byte[] ConnectionManager = [0x20, 0x06, 0x24, 0x01];
}
// Logix/LogixTypeCode.cs
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;

public static class LogixTypeCode
{
    public const ushort Bool = 0xC1, SInt = 0xC2, Int = 0xC3, DInt = 0xC4, LInt = 0xC5, USInt = 0xC6,
        UInt = 0xC7, UDInt = 0xC8, ULInt = 0xC9, Real = 0xCA, LReal = 0xCB, BoolArray = 0xD3, Struct = 0x02A0;

    public static int ByteWidth(ushort code) => code switch
    {
        Bool or SInt or USInt => 1,
        Int or UInt => 2,
        DInt or UDInt or Real or BoolArray => 4,
        LInt or ULInt or LReal => 8,
        _ => -1,
    };
}

Fix the "9108546573744449 4E54" spacing in the test by stripping spaces (already done with .Replace(" ", "")).

  • Step 4: Run. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~CipPath|FullyQualifiedName~LogixTypeCode" → pass.
  • Step 5: Commit. git commit -am-style, with explicit paths: git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix && git commit -m "feat(ab-transport): CIP route/symbolic path encoders and Logix type codes"

Task 7: CIP service codecs (Connection Manager, MSP, Logix Read/Write, symbol + template)

Goal: Pure request builders and reply parsers for every CIP service the native AbCip path needs, each proven byte-exact against the Task 4 captures where a capture exists.

Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs (Unconnected Send 0x52, Forward Open 0x54, Large Forward Open 0x5B, Forward Close 0x4E)
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/MultipleServicePacket.cs (0x0A)
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixServices.cs (Read Tag 0x4C, Read Tag Fragmented 0x52, Write Tag 0x4D, Write Tag Fragmented 0x53)
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/SymbolObjectServices.cs (Get Instance Attribute List 0x55 on class 0x6B)
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/TemplateObjectServices.cs (Get Attribute List 0x03 + Read 0x4C on class 0x6C)
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ConnectionManagerServicesTests.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/MultipleServicePacketTests.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixServicesTests.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/SymbolObjectServicesTests.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/TemplateObjectServicesTests.cs

Acceptance Criteria:

  • Unconnected Send 0x52 to ConnectionManager builds: priority/tick 0x0A, timeout ticks 0x0E, embedded request size u16, the embedded request, a pad byte if that size is odd, the route path size in words u8, reserved 0x00, then the route. When the route is empty (Micro800), the builder is not used and the embedded request is sent directly.
  • Forward Open. ForwardOpen(ForwardOpenParams p) emits:
    • service 0x54 (or 0x5B when p.Large), path size 02, ConnectionManager, priority/tick 0x0A, timeout ticks 0x0E;
    • O→T id 00000000, T→O id p.TOConnectionId, connection serial p.ConnectionSerial, vendor id p.VendorId, originator serial p.OriginatorSerial;
    • timeout multiplier p.TimeoutMultiplier, 3 reserved bytes;
    • O→T RPI u32, then O→T params: u16 0x4200 | size (regular) or u32 0x42000000 | size (large);
    • T→O RPI, then T→O params (same encoding);
    • transport/trigger 0xA3, connection path size in words, connection path = EncodeRoute(cipPath) + MessageRouter.
  • ParseForwardOpenReply returns (OTConnectionId, TOConnectionId, OTApiMicros, TOApiMicros) on success. On a refusal it surfaces the general and extended status.
  • ShouldFallBackToRegularOpen(CipReply) is true when the reply is general 0x08 (Service Not Supported — pre-v20 Logix and other targets answer a Large Forward Open this way, and libplctag falls back on it too) or general 0x01 with extended 0x0109 (invalid connection size). Task 9 uses it.
  • ForwardClose(serial, vendor, origSerial, route) → 4E 02 20 06 24 01 0A 0E <serial> <vendor> <origSerial> <pathWords> 00 <route + MessageRouter>.
  • MSP. MultipleServicePacket.Build(IReadOnlyList<byte[]> requests) → 0A 02 20 02 24 01, count u16, then offsets u16[count] measured from the start of the count field, then the requests concatenated. Parse(reply, count) returns one CipReply per embedded reply. BuildSize(sizes) returns the exact byte size of an MSP for the given request sizes, without allocating (Task 10 uses it for packing).
  • Read/Write Tag:
    • ReadTag(path, elements) → 4C <pathWords> <path> <elements u16>.
    • ReadTagFragmented(path, elements, offset) → 52 … <elements u16> <offset u32>.
    • WriteTag(path, typeCode, structHandle?, elements, data) → 4D … <type u16> [<handle u16> when type == 0x02A0] <elements u16> <data>.
    • WriteTagFragmented → 53 … <type> [<handle>] <elements> <offset u32> <data>.
    • ParseReadReply(CipReply) → (ushort TypeCode, ushort? StructHandle, ReadOnlyMemory<byte> Data). For 0x02A0 the next u16 is the handle.
  • Symbol listing. SymbolObjectServices.GetInstanceAttributeList(programName?, startInstance) →
    • [91 len "Program:X" pad], then 20 6B 25 00 <start u16>, then attribute count 04 00 and attributes 02 00 07 00 08 00 01 00 (type, element length, array dims, name).
    • Each reply entry, as the controller sends it, is u32 instance, u16 type, u16 elemLen, u32×3 dims, u16 nameLen, name — exactly the entry layout CipSymbolObjectDecoder expects. ParseListingReply returns (byte[] Entries, uint LastInstance, bool More), where More ⇔ general status 0x06.
    • ⚠️ If Captures/logix-tags-listing.hex shows libplctag requesting a different attribute set or order, match the capture and update this criterion. The decoder layout is the invariant; the attribute list is the means.
  • Templates:
    • TemplateObjectServices.GetAttributes(templateId) → 03 03 20 6C 25 00 <id u16> 04 00 04 00 05 00 02 00 01 00. That is attributes 4 (definition size, words), 5 (structure size, bytes), 2 (member count) and 1 (handle).
    • ParseAttributes → (uint DefWords, uint StructBytes, ushort Members, ushort Handle); each attribute's reply is id u16, status u16, value.
    • ReadTemplate(templateId, offset, bytes) → 4C 03 20 6C 25 00 <id u16> <offset u32> <bytes u16>, with More ⇔ status 0x06.
    • TemplateObjectServices.BuildDecoderBuffer(id, attrs, rawTemplate) → the 14-byte header + raw bytes from Global Constraints.
  • Capture parity tests (each extracts the CIP payload via CaptureFile.CipPayload):
    • The ReadTag request in logix-read-dint.hex equals our ReadTag(EncodeSymbolic("TestDINT"), 1). If libplctag wrapped it in an MSP or Unconnected Send, unwrap the same way in the test, and document that wrapping in a test comment.
    • ParseReadReply on the captured reply yields TypeCode == 0xC4 and 4 data bytes.
    • The captured Forward Open request equals ours when built with the captured values of the serials and connection ids, which are read out of the capture.
    • The captured @tags reply entries decode through CipSymbolObjectDecoder (reference the AbCip project from the test project only if that is needed; otherwise assert the entry byte layout directly).

Verify: dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~Services|FullyQualifiedName~MultipleServicePacket" → pass.

Steps:

  • Step 1: Write the golden-byte tests (failing) for each builder above, one [Fact] per acceptance row. For example:
[Fact]
public void Forward_close_frame()
{
    var bytes = ConnectionManagerServices.ForwardClose(connectionSerial: 0x1234, vendorId: 0x1337, originatorSerial: 0xCAFEBABE, route: [0x01, 0x00]);
    Convert.ToHexString(bytes).ShouldBe("4E02200624010A0E" + "3412" + "3713" + "BEBAFECA" + "03" + "00" + "0100" + "20022401");
}

[Fact]
public void Msp_offsets_are_measured_from_the_count_field()
{
    var a = new byte[] { 0x4C, 0x02, 0x91, 0x01, 0x41, 0x00, 0x01, 0x00 };  // 8 bytes
    var b = new byte[] { 0x4C, 0x02, 0x91, 0x01, 0x42, 0x00, 0x01, 0x00 };
    Convert.ToHexString(MultipleServicePacket.Build([a, b])).ShouldBe(
        "0A02200224010200" + "0600" + "0E00" + Convert.ToHexString(a) + Convert.ToHexString(b));
    MultipleServicePacket.BuildSize([8, 8]).ShouldBe(6 + 2 + 4 + 16);
}

[Fact]
public void Read_reply_with_struct_handle()
{
    var reply = CipReply.Parse(Convert.FromHexString("CC000000" + "A002" + "CE0F" + "05000000" + "48656C6C6F"));
    var (type, handle, data) = LogixServices.ParseReadReply(reply);
    type.ShouldBe((ushort)0x02A0); handle.ShouldBe((ushort)0x0FCE); data.Length.ShouldBe(9);
}
  • Step 2: Run and confirm failure. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~Services|FullyQualifiedName~MultipleServicePacket" → compile error.

  • Step 3: Implement. The core shapes follow; complete every builder named in the acceptance criteria.

// Cip/ConnectionManagerServices.cs
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;

public sealed record ForwardOpenParams(
    bool Large, uint TOConnectionId, ushort ConnectionSerial, ushort VendorId, uint OriginatorSerial,
    byte TimeoutMultiplier, uint RpiMicros, int ConnectionSize, byte[] Route);

public sealed record ForwardOpenReply(uint OTConnectionId, uint TOConnectionId, uint OTApiMicros, uint TOApiMicros);

public static class ConnectionManagerServices
{
    public const byte PriorityTick = 0x0A, TimeoutTicks = 0x0E;

    public static byte[] UnconnectedSend(ReadOnlySpan<byte> embedded, ReadOnlySpan<byte> route)
    {
        var w = new CipWriter().U8(0x52).U8(2).Bytes(CipPath.ConnectionManager)
            .U8(PriorityTick).U8(TimeoutTicks).U16((ushort)embedded.Length).Bytes(embedded);
        if ((embedded.Length & 1) == 1) w.U8(0);
        return w.U8((byte)(route.Length / 2)).U8(0).Bytes(route).ToArray();
    }

    public static byte[] ForwardOpen(ForwardOpenParams p)
    {
        var connPath = new CipWriter().Bytes(p.Route).Bytes(CipPath.MessageRouter).ToArray();
        var w = new CipWriter().U8(p.Large ? (byte)0x5B : (byte)0x54).U8(2).Bytes(CipPath.ConnectionManager)
            .U8(PriorityTick).U8(TimeoutTicks)
            .U32(0).U32(p.TOConnectionId).U16(p.ConnectionSerial).U16(p.VendorId).U32(p.OriginatorSerial)
            .U8(p.TimeoutMultiplier).U8(0).U8(0).U8(0);
        WriteSide(w, p);
        WriteSide(w, p);
        return w.U8(0xA3).U8((byte)(connPath.Length / 2)).Bytes(connPath).ToArray();
    }

    private static void WriteSide(CipWriter w, ForwardOpenParams p)
    {
        w.U32(p.RpiMicros);
        if (p.Large) w.U32(0x42000000u | (uint)p.ConnectionSize);
        else w.U16((ushort)(0x4200 | (p.ConnectionSize & 0x1FF)));
    }

    public static ForwardOpenReply ParseForwardOpenReply(CipReply reply)
    {
        if (reply.GeneralStatus != 0)
            throw new AbTransportException(reply.Status == AbStatus.BadReply ? AbStatus.BadConnection : reply.Status,
                $"Forward Open refused: general 0x{reply.GeneralStatus:X2}, extended {string.Join(",", reply.ExtendedStatus.Select(e => $"0x{e:X4}"))}.");
        var r = new CipReader(reply.Data.Span);
        var ot = r.U32(); var to = r.U32(); r.U16(); r.U16(); r.U32();
        return new ForwardOpenReply(ot, to, r.U32(), r.U32());
    }

    public static byte[] ForwardClose(ushort connectionSerial, ushort vendorId, uint originatorSerial, ReadOnlySpan<byte> route)
    {
        var connPath = new CipWriter().Bytes(route).Bytes(CipPath.MessageRouter).ToArray();
        return new CipWriter().U8(0x4E).U8(2).Bytes(CipPath.ConnectionManager).U8(PriorityTick).U8(TimeoutTicks)
            .U16(connectionSerial).U16(vendorId).U32(originatorSerial)
            .U8((byte)(connPath.Length / 2)).U8(0).Bytes(connPath).ToArray();
    }
}

The large-open fallback needs the raw statuses, so add: public static bool ShouldFallBackToRegularOpen(CipReply r) => r.GeneralStatus == 0x08 || (r.GeneralStatus == 0x01 && r.ExtendedStatus.Length > 0 && r.ExtendedStatus[0] == 0x0109); Test both branches, plus a negative case (0x01/0x0100 "connection in use" must NOT fall back).

// Logix/LogixServices.cs
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;

public static class LogixServices
{
    private static CipWriter Start(byte service, ReadOnlySpan<byte> path) =>
        new CipWriter().U8(service).U8((byte)(path.Length / 2)).Bytes(path);

    public static byte[] ReadTag(ReadOnlySpan<byte> path, ushort elements) => Start(0x4C, path).U16(elements).ToArray();
    public static byte[] ReadTagFragmented(ReadOnlySpan<byte> path, ushort elements, uint offset) =>
        Start(0x52, path).U16(elements).U32(offset).ToArray();

    public static byte[] WriteTag(ReadOnlySpan<byte> path, ushort type, ushort? handle, ushort elements, ReadOnlySpan<byte> data)
    {
        var w = Start(0x4D, path).U16(type);
        if (type == LogixTypeCode.Struct) w.U16(handle ?? throw new ArgumentException("Struct write needs a handle."));
        return w.U16(elements).Bytes(data).ToArray();
    }

    public static byte[] WriteTagFragmented(ReadOnlySpan<byte> path, ushort type, ushort? handle, ushort elements, uint offset, ReadOnlySpan<byte> data)
    {
        var w = Start(0x53, path).U16(type);
        if (type == LogixTypeCode.Struct) w.U16(handle ?? throw new ArgumentException("Struct write needs a handle."));
        return w.U16(elements).U32(offset).Bytes(data).ToArray();
    }

    public static (ushort TypeCode, ushort? StructHandle, ReadOnlyMemory<byte> Data) ParseReadReply(CipReply reply)
    {
        var r = new CipReader(reply.Data.Span);
        var type = r.U16();
        ushort? handle = type == LogixTypeCode.Struct ? r.U16() : null;
        return (type, handle, reply.Data[r.Position..]);
    }
}

Implement MultipleServicePacket, SymbolObjectServices and TemplateObjectServices to the byte layouts in the acceptance criteria, using CipWriter/CipReader the same way.

  • Step 4: Write and run the capture-parity tests. If a parity test fails because libplctag sends something different from what this plan specifies (for example a different timeout-ticks byte, or different symbol attributes), libplctag ⇄ ab_server is the parity reference for framing constants: change the constant to match, and record the before/after in the test comment. Never change a layout the existing real-press decoder tests rely on.
  • Step 5: Run. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests → all pass.
  • Step 6: Commit. git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix && git commit -m "feat(ab-transport): Connection Manager, MSP, Logix read/write, symbol and template service codecs"

Task 8: PCCC codec (Execute PCCC, SLC/MicroLogix typed logical, PLC-5 typed)

Goal: Pure builders and parsers that turn a PCCC file address into an Execute-PCCC CIP request, for both SLC/MicroLogix/LogixPccc and PLC-5. Byte parity is proven against the Task 4 captures.

Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccFileType.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccAddress.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccServices.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccAddressTests.cs

Acceptance Criteria:

  • PcccAddress(string FileLetter, int? FileNumber, int Element, int? SubElement) is the transport-side address. It does not depend on AbLegacyAddress; Task 14 maps between the two.
  • File type codes: O=0x8B, I=0x8C, S=0x84, B=0x85, T=0x86, C=0x87, R=0x88, N=0x89, F=0x8A, ST=0x8D, A=0x8E, L=0x91. Implicit file numbers: O→0, I→1, S→2. Verify every code against slc500-read-n7.hex / slc500-read-f8.hex; the capture wins if it disagrees.
  • Sub-element indices: T/C → PRE=1, ACC=2 (word offsets within the 3-word element, where word 0 is the control bits), and R → LEN=1, POS=2. The bit sub-elements (EN/TT/DN/CU/CD/OV/UN) read word 0 and are returned as (sub=0, bit). PcccAddress.ResolveSubElement returns (int word, int? bit).
  • PcccServices.ExecutePccc(requestorVendor, requestorSerial, pccc) → 4B 02 20 67 24 01 07 <vendor u16> <serial u32> <pccc bytes>.
  • PcccServices.SlcTypedRead(tns, addr, byteCount) → PCCC 0F 00 <tns u16> A2 <byteCount u8> <file u8*> <type u8> <element u8*> <sub u8*>, where u8* is a single byte when < 255, else FF lo hi.
  • PcccServices.SlcTypedWrite(tns, addr, data) → 0F 00 <tns> AA <byteCount> <file*> <type> <element*> <sub*> <data>.
  • Byte-count limit (review finding 12). byteCount is a single byte on the wire. SlcTypedRead/SlcTypedWrite (and the PLC-5 builders) throw AbTransportException(TooLarge) when the requested or supplied byte count exceeds PcccServices.MaxDataBytes = 236; a silent (byte) wrap would be a corrupt write. Test 236 (ok) and 237 (throws) for both. Task 14 chunks larger arrays.
  • PcccServices.Plc5TypedRead(tns, addr, elements) → 0F 00 <tns u16> 68 <packetOffset u16 0> <totalTrans u16 elements> <PLC-5 system address> <size u16 elements>.
    • The system address is a level-mask byte (bit0 = file level present? derive from plc5-read-n7.hex) followed by level values ($N7:0 ⇒ data table section, file 7, element 0), each u8* encoded.
    • Plc5TypedWrite is 0F 00 <tns> 67 <offset u16> <total u16> <sysaddr> <type/size param> <data>.
    • ⚠️ The PLC-5 framing must be copied from the capture. If ab_server's --plc=PLC/5 path is not exercised by libplctag in the capture, mark the PLC-5 builders [Obsolete("Unverified — no capture")], make the native AbLegacy runtime throw AbTransportException(Unsupported) for plc5, and record that in the Task 20 docs. Do not ship unverified framing.
  • PcccServices.ParseReply(cipReply) strips the requestor-id echo (len u8 + len-1 bytes), reads cmd u8 (0x4F), sts u8, tns u16, then extSts u8 if sts == 0xF0, then the data. Non-zero STS → AbTransportException(AbStatusCodes.FromPccc(sts, ext)). A TNS that differs from the request → BadReply.
  • Capture parity: SlcTypedRead for N7:0 / F8:0 equals the PCCC payload inside the captured Execute PCCC request, with the TNS normalized; ParseReply on the captured reply yields the 2 / 4 data bytes.

Verify: dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~Pccc" → pass.

Steps:

  • Step 1: Read the captures first. Decode slc500-read-n7.hex by hand (write the decode as comments in PcccServicesTests) to fix the exact byte for each field. Check: is the request inside an Unconnected Send? Is it connected via Forward Open? What vendor/serial does libplctag put in the requestor id? This decode is the spec for the steps below.
  • Step 2: Write the failing tests: golden bytes for ExecutePccc, SlcTypedRead(N7:0, 2), SlcTypedRead(F8:3, 4), SlcTypedWrite(N7:0, [0x2A,0x00]), an element ≥ 255 (N7:300 ⇒ FF 2C 01), and the T4:0.ACC sub-element 2. Parse tests for success, sts=0x10 ⇒ Unsupported, sts=0xF0 ext=0x06 ⇒ NotFound, and a TNS mismatch ⇒ BadReply. Add the capture-parity facts.
  • Step 3: Run and confirm failure. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter FullyQualifiedName~Pccc → compile error.
  • Step 4: Implement.
// Pccc/PcccServices.cs (core)
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;

public static class PcccServices
{
    public static byte[] ExecutePccc(ushort vendor, uint serial, ReadOnlySpan<byte> pccc) =>
        new CipWriter().U8(0x4B).U8(2).U8(0x20).U8(0x67).U8(0x24).U8(0x01)
            .U8(7).U16(vendor).U32(serial).Bytes(pccc).ToArray();

    private static void Field(CipWriter w, int v)
    {
        if (v < 0xFF) w.U8((byte)v); else w.U8(0xFF).U16((ushort)v);
    }

    public static byte[] SlcTypedRead(ushort tns, PcccAddress a, byte byteCount)
    {
        var w = new CipWriter().U8(0x0F).U8(0).U16(tns).U8(0xA2).U8(byteCount);
        Field(w, a.EffectiveFileNumber); w.U8(a.FileTypeCode); Field(w, a.Element); Field(w, a.ResolveSubElement().Word);
        return w.ToArray();
    }

    public static byte[] SlcTypedWrite(ushort tns, PcccAddress a, ReadOnlySpan<byte> data)
    {
        var w = new CipWriter().U8(0x0F).U8(0).U16(tns).U8(0xAA).U8((byte)data.Length);
        Field(w, a.EffectiveFileNumber); w.U8(a.FileTypeCode); Field(w, a.Element); Field(w, a.ResolveSubElement().Word);
        return w.Bytes(data).ToArray();
    }

    public static ReadOnlyMemory<byte> ParseReply(CipReply reply, ushort expectedTns)
    {
        if (reply.GeneralStatus != 0)
            throw new AbTransportException(reply.Status, $"Execute PCCC CIP status 0x{reply.GeneralStatus:X2}.");
        var span = reply.Data.Span;
        var r = new CipReader(span);
        var idLen = r.U8(); r.Bytes(idLen - 1);
        r.U8(); // cmd | 0x40
        var sts = r.U8();
        var tns = r.U16();
        byte? ext = sts == 0xF0 ? r.U8() : null;
        if (sts != 0) throw new AbTransportException(AbStatusCodes.FromPccc(sts, ext), $"PCCC STS 0x{sts:X2}{(ext is { } e ? $" EXT 0x{e:X2}" : "")}.");
        if (tns != expectedTns) throw new AbTransportException(AbStatus.BadReply, $"PCCC TNS {tns} != {expectedTns}.");
        return reply.Data[r.Position..];
    }
}

PcccAddress exposes FileTypeCode (from the table), EffectiveFileNumber (explicit, or the implicit number for O/I/S), and ResolveSubElement(). The PLC-5 builders come from the capture decode in Step 1.

  • Step 5: Run. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter FullyQualifiedName~Pccc → pass.
  • Step 6: Commit. git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc && git commit -m "feat(ab-transport): PCCC Execute-PCCC codec for SLC/MicroLogix typed logical and PLC-5 typed access"

Task 9: CipConnection + DeviceSessionPool (routing modes, keep-alive, press-safety bounds)

Goal: Own one EIP session, one CIP connection (when routed Connected) and one RequestScheduler per device key. Hand out ref-counted leases exposing both, keep idle class-3 connections alive, and rate-limit Forward Opens so that no failure mode can storm a PLC.

Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/DeviceKey.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionOptions.cs (includes the CipRouting enum)
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs (implements ICipChannel)
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ForwardOpenThrottle.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/DeviceSessionPool.cs (includes ConnectionLease)
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj (InternalsVisibleTo for ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests, ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests, ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests)
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ForwardOpenThrottleTests.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/DeviceSessionPoolTests.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipTarget.cs (a FakeEipServer.Handler that answers Forward Open / Forward Close / Unconnected Send / Direct / connected data, and counts each)

Acceptance Criteria:

  • DeviceKey(string Host, int Port, string CipPath, string PlcAttribute) is a record with case-insensitive Host/PlcAttribute equality.
  • ConnectionOptions:
    • Routing (CipRouting { Connected, UnconnectedSend, Direct }) and AllowPacking (bool).
    • Timeout and ConnectionSize (int).
    • MinForwardOpenInterval (default 1 s).
    • RpiMicros (default 2 000 000) and TimeoutMultiplier (default 1 ⇒ ×8, so connection expiry = 16 s).
    • KeepAliveInterval (default = RPI × 8 / 2 = 8 s).
    • VendorId (0x1337) and OriginatorSerial (random per process).
  • Routing semantics (B3):
    • Connected:
      • On first use: TCP connect, RegisterSession, Forward Open. When ConnectionSize > 511 that is a Large FO (0x5B); otherwise a regular one.
      • If ShouldFallBackToRegularOpen (T7) is true for the reply, it retries once, in the same throttled attempt, with a regular open at Math.Min(ConnectionSize, 504).
      • Requests go via SendUnitDataAsync. The client assigns the sequence (T5).
      • MaxRequestBytes = negotiatedSize - 2.
    • UnconnectedSend: requests go via SendRRData wrapped in Unconnected Send 0x52 with the route. MaxRequestBytes = 504 - (12 + route.Length).
    • Direct: requests go via SendRRData carrying the CIP request bare, even when a CIP path is configured; the path is ignored. MaxRequestBytes = 504.
  • Reconnect: when the client is faulted (timeout or I/O, never caller cancellation — see T5), the next SendAsync reconnects through the throttle. The failing call itself throws its original exception.
  • Keep-alive (review finding 8, spec §9). In Connected mode, a timer sends Get Attribute Single (0E 03 20 01 24 01 30 01: Identity, instance 1, attribute 1 = Vendor ID) through the connection whenever no request has been sent for KeepAliveInterval.
    • A keep-alive failure faults the connection like any other.
    • Keep-alives count towards RequestsSent.
    • Test with fake time: no keep-alive while traffic flows; exactly one after an idle KeepAliveInterval.
  • No null-race (finding 16). The open path returns the (client, connection) pair it established, and SendAsync uses that pair rather than re-reading fields after the lock.
  • Clean disposal. Disposal never disposes a SemaphoreSlim that waiters may still be queued on. It sets a _disposed flag checked under the lock, and later callers get AbTransportException(Abort).
  • ForwardOpenThrottle (per DeviceKey; the pool keeps throttles for the process lifetime, so a reconnect after the last release is still limited):
    • At most 1 open attempt in flight.
    • An attempt within MinForwardOpenInterval of the previous attempt start waits for the remainder. If the caller's token fires first, it throws AbTransportException(BadConnection, "Forward Open throttled").
    • It uses an injected TimeProvider.
  • Storm test: against a FakeCipTarget refusing every Forward Open, 50 concurrent SendAsync callers over 3 s of fake time produce ≤ 4 Forward Open frames.
  • DeviceSessionPool:
    • Acquire(DeviceKey, ConnectionOptions) → ConnectionLease. ConnectionLease : IAsyncDisposable exposes ICipChannel Channel and RequestScheduler Scheduler.
    • All leases for one key share one CipConnection and one RequestScheduler (B1). The scheduler is built as new RequestScheduler(connection, options.AllowPacking). A second Acquire with different ConnectionOptions for the same key reuses the first entry's options; that is logged once and pinned by a test.
    • The last lease's dispose disposes the scheduler, sends Forward Close (Connected only) and then UnRegisterSession.
    • A later acquire on the same key builds new instances.
    • Thread-safe: 100 parallel acquire/dispose tasks pass.
  • DeviceSessionPool.Shared is the process-wide instance; tests build their own.
  • Diagnostics for the gates (Tasks 18 and 21): internal static int ForwardOpenCount (one increment per Forward Open frame sent, including the fallback) and internal static int RequestsSent (one per channel send, keep-alives included), both via Interlocked.

Verify: dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~CipConnection|FullyQualifiedName~ForwardOpenThrottle|FullyQualifiedName~DeviceSessionPool" → pass; the storm test passes 20/20 consecutive runs.

Steps:

  • Step 1: Write the failing tests.
    • Use ManualTimeProvider : TimeProvider in Fakes/ (about 20 lines: settable UtcNow, plus timers fired by Advance(TimeSpan)). Check Directory.Packages.props for Microsoft.Extensions.TimeProvider.Testing first; use FakeTimeProvider if it is already pinned, and never add a package.
    • Write these tests:
      • Second_attempt_inside_interval_waits
      • Attempt_after_interval_proceeds_immediately
      • Only_one_attempt_in_flight
      • Refusing_target_sees_at_most_one_forward_open_per_interval
      • Large_open_service_not_supported_falls_back_to_regular_open_once
      • Large_open_bad_size_falls_back_to_regular_open_once
      • Connection_in_use_does_not_fall_back
      • Direct_routing_sends_bare_cip_even_with_a_path
      • UnconnectedSend_routing_wraps_in_0x52
      • Connected_routing_never_sends_bare
      • Caller_cancellation_does_not_reconnect (cancel mid-request, then send again ⇒ ForwardOpenCount unchanged)
      • Timeout_reconnects_through_the_throttle
      • Idle_connection_sends_one_keepalive_per_interval
      • Traffic_suppresses_keepalive
      • Leases_for_one_key_share_one_scheduler
      • Last_lease_dispose_sends_forward_close_then_unregister
      • Concurrent_acquire_and_release_is_safe
      • Send_after_dispose_throws_Abort
  • Step 2: Run and confirm failure. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~CipConnection|FullyQualifiedName~ForwardOpenThrottle|FullyQualifiedName~DeviceSessionPool" → compile error.
  • Step 3: Implement the throttle.
// Cip/ForwardOpenThrottle.cs
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;

/// <summary>Press safety: at most one Forward Open attempt in flight per device and a minimum
/// interval between attempt starts. A misconfigured client once drove ~15 Forward Opens/s at a
/// production press (ignitionoee vite-press-oee-bind.md, 2026-09-23); this is what prevents it.</summary>
public sealed class ForwardOpenThrottle(TimeSpan minInterval, TimeProvider time)
{
    private readonly SemaphoreSlim _inFlight = new(1, 1);
    private DateTimeOffset? _lastStart;

    public async Task<T> RunAsync<T>(Func<CancellationToken, Task<T>> open, CancellationToken ct)
    {
        await _inFlight.WaitAsync(ct).ConfigureAwait(false);
        try
        {
            if (_lastStart is { } last)
            {
                var wait = last + minInterval - time.GetUtcNow();
                if (wait > TimeSpan.Zero)
                {
                    try { await Task.Delay(wait, time, ct).ConfigureAwait(false); }
                    catch (OperationCanceledException) when (ct.IsCancellationRequested)
                    { throw new AbTransportException(AbStatus.BadConnection, "Forward Open throttled; caller deadline reached."); }
                }
            }
            _lastStart = time.GetUtcNow();
            return await open(ct).ConfigureAwait(false);
        }
        finally { _inFlight.Release(); }
    }
}

The throttle's semaphore is never disposed: throttles live for the process.

  • Step 4: Implement CipConnection.
// Cip/CipConnection.cs
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;

public sealed class CipConnection : ICipChannel, IAsyncDisposable
{
    internal static int ForwardOpenCount;
    internal static int RequestsSent;

    private sealed record Open(EncapsulationClient Client, ForwardOpenReply? Conn);

    private readonly DeviceKey _key;
    private readonly ConnectionOptions _o;
    private readonly ForwardOpenThrottle _throttle;
    private readonly TimeProvider _time;
    private readonly byte[] _route;
    private readonly SemaphoreSlim _state = new(1, 1);   // never disposed (see DisposeAsync)
    private readonly ITimer? _keepAlive;
    private Open? _open;
    private int _negotiatedSize;
    private long _lastSendTicks;
    private bool _disposed;
    private readonly ushort _connSerial = (ushort)Random.Shared.Next(1, ushort.MaxValue);
    private readonly uint _toId = (uint)Random.Shared.Next();

    public CipConnection(DeviceKey key, ConnectionOptions o, ForwardOpenThrottle throttle, TimeProvider time)
    {
        _key = key; _o = o; _throttle = throttle; _time = time;
        _route = CipPath.EncodeRoute(key.CipPath);
        if (o.Routing == CipRouting.Connected)
            _keepAlive = time.CreateTimer(_ => _ = KeepAliveAsync(), null, o.KeepAliveInterval, o.KeepAliveInterval);
    }

    public int MaxRequestBytes => _o.Routing switch
    {
        CipRouting.Connected => (_negotiatedSize > 0 ? _negotiatedSize : Math.Min(_o.ConnectionSize, 504)) - 2,
        CipRouting.UnconnectedSend => 504 - (12 + _route.Length),
        _ => 504,
    };

    public async Task<ReadOnlyMemory<byte>> SendAsync(ReadOnlyMemory<byte> cip, CancellationToken ct)
    {
        var open = await EnsureOpenAsync(ct).ConfigureAwait(false);
        Interlocked.Increment(ref RequestsSent);
        Interlocked.Exchange(ref _lastSendTicks, _time.GetTimestamp());
        return _o.Routing switch
        {
            CipRouting.Connected => await open.Client.SendUnitDataAsync(open.Conn!.OTConnectionId, cip, _o.Timeout, ct).ConfigureAwait(false),
            CipRouting.UnconnectedSend => await open.Client.SendRRDataAsync(ConnectionManagerServices.UnconnectedSend(cip.Span, _route), _o.Timeout, ct).ConfigureAwait(false),
            _ => await open.Client.SendRRDataAsync(cip, _o.Timeout, ct).ConfigureAwait(false),
        };
    }

    private async Task KeepAliveAsync()
    {
        if (_disposed || _open is not { Client.IsConnected: true }) return;
        if (_time.GetElapsedTime(Interlocked.Read(ref _lastSendTicks)) < _o.KeepAliveInterval) return;
        try { await SendAsync(new byte[] { 0x0E, 0x03, 0x20, 0x01, 0x24, 0x01, 0x30, 0x01 }, CancellationToken.None).ConfigureAwait(false); }
        catch (AbTransportException) { /* the client is now faulted; the next real send reconnects through the throttle */ }
    }

    private async Task<Open> EnsureOpenAsync(CancellationToken ct)
    {
        if (_open is { } o && o.Client.IsConnected) return o;
        await _state.WaitAsync(ct).ConfigureAwait(false);
        try
        {
            if (_disposed) throw new AbTransportException(AbStatus.Abort, "Connection disposed.");
            if (_open is { } o2 && o2.Client.IsConnected) return o2;
            return _open = await _throttle.RunAsync(OpenAsync, ct).ConfigureAwait(false);
        }
        finally { _state.Release(); }
    }

    private async Task<Open> OpenAsync(CancellationToken ct)
    {
        if (_open is not null) { await _open.Client.DisposeAsync().ConfigureAwait(false); _open = null; }
        var client = await EncapsulationClient.ConnectAsync(_key.Host, _key.Port, _o.Timeout, ct).ConfigureAwait(false);
        if (_o.Routing != CipRouting.Connected) return new Open(client, null);
        try
        {
            var large = _o.ConnectionSize > 511;
            _negotiatedSize = _o.ConnectionSize;
            var reply = await ForwardOpenAsync(client, large, _negotiatedSize, ct).ConfigureAwait(false);
            if (large && ConnectionManagerServices.ShouldFallBackToRegularOpen(reply))
            {
                _negotiatedSize = Math.Min(_o.ConnectionSize, 504);
                reply = await ForwardOpenAsync(client, false, _negotiatedSize, ct).ConfigureAwait(false);
            }
            return new Open(client, ConnectionManagerServices.ParseForwardOpenReply(reply));
        }
        catch { await client.DisposeAsync().ConfigureAwait(false); throw; }
    }

    private async Task<CipReply> ForwardOpenAsync(EncapsulationClient client, bool large, int size, CancellationToken ct)
    {
        Interlocked.Increment(ref ForwardOpenCount);
        var p = new ForwardOpenParams(large, _toId, _connSerial, _o.VendorId, _o.OriginatorSerial, _o.TimeoutMultiplier, _o.RpiMicros, size, _route);
        return CipReply.Parse(await client.SendRRDataAsync(ConnectionManagerServices.ForwardOpen(p), _o.Timeout, ct).ConfigureAwait(false));
    }

    public async ValueTask DisposeAsync()
    {
        await _state.WaitAsync().ConfigureAwait(false);
        Open? open;
        try { _disposed = true; open = _open; _open = null; }
        finally { _state.Release(); }
        if (_keepAlive is not null) await _keepAlive.DisposeAsync().ConfigureAwait(false);
        if (open is null) return;
        if (open.Conn is not null && open.Client.IsConnected)
        {
            try
            {
                using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
                await open.Client.SendRRDataAsync(ConnectionManagerServices.ForwardClose(_connSerial, _o.VendorId, _o.OriginatorSerial, _route), TimeSpan.FromSeconds(1), cts.Token).ConfigureAwait(false);
            }
            catch (Exception ex) when (ex is AbTransportException or OperationCanceledException) { /* PLC expires it after RPI × multiplier */ }
        }
        await open.Client.DisposeAsync().ConfigureAwait(false);
    }
}
  • Step 5: Implement DeviceSessionPool + ConnectionLease.
    • Keep a Dictionary<DeviceKey, Entry> under a lock, where Entry = (CipConnection Conn, RequestScheduler Scheduler, ConnectionOptions Options, int RefCount).
    • Keep a separate, never-pruned Dictionary<DeviceKey, ForwardOpenThrottle>.
    • Acquire creates the entry on first use (new CipConnection(key, o, throttle, time), new RequestScheduler(conn, o.AllowPacking)) and increments RefCount.
    • ConnectionLease.DisposeAsync is idempotent (Interlocked flag). It decrements the count and, at zero, removes the entry; outside the lock it awaits Scheduler.DisposeAsync(), then Conn.DisposeAsync().
    • The pool constructor takes an optional TimeProvider (default TimeProvider.System).
  • Step 6: Run. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests → all pass, then run Refusing_target_sees_at_most_one_forward_open_per_interval 20× to check it is not flaky.
  • Step 7: Commit. git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests && git commit -m "feat(ab-transport): per-device connection with routing modes, shared scheduler, keep-alive and Forward Open throttle"

Task 10: RequestScheduler — MSP coalescing + fragmentation

Goal: A per-channel scheduler that coalesces concurrent CIP requests into Multiple Service Packets within MaxRequestBytes, keeps one request in flight, fans each reply back to its own waiter, and runs fragmented read/write loops for payloads that do not fit in one frame.

Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/RequestScheduler.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTagClient.cs (read/write helpers over the scheduler, including the fragment loops)
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/RequestSchedulerTests.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTagClientTests.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipChannel.cs

Acceptance Criteria:

  • RequestScheduler(ICipChannel channel, bool allowPacking) exposes Task<CipReply> SendAsync(byte[] request, CancellationToken ct).
  • Packing: while one request is in flight, new requests queue. When the channel frees, the scheduler takes queued requests in FIFO order while MultipleServicePacket.BuildSize(sizes) <= channel.MaxRequestBytes. More than one taken ⇒ one MSP. Exactly one ⇒ sent bare. allowPacking == false ⇒ always bare.
  • Boundary tests: with MaxRequestBytes = M, two requests whose MSP size is exactly M pack into one frame, and two whose MSP size is M + 1 go as two frames.
  • A single request larger than MaxRequestBytes is sent bare. Callers must fragment; LogixTagClient does.
  • Reply fan-out: each waiter gets its own embedded CipReply, with its own general status, so one failing service in an MSP fails only its own waiter. If the MSP envelope itself fails (non-zero general status other than 0x1E embedded-service-error), or the channel throws, every waiter in that batch faults with the same AbTransportException.
  • Cancellation:
    • A waiter cancelled while queued is removed and never sent.
    • A waiter cancelled while its batch is in flight gets OperationCanceledException, and the batch's other waiters still complete.
    • The channel call always uses the scheduler's own _stop token, never a waiter's, for single sends as well as MSPs (B2). So one caller's cancellation can never abort, or fault, a shared exchange.
    • Each waiter's CancellationTokenRegistration is stored on its Pending record and disposed when the TCS completes (review finding 10). A test makes 10 000 sends on one long-lived token and asserts no growth: measure with a counting CancellationTokenSource subclass, or assert Pending registrations are disposed via a test hook.
  • Owner: one scheduler per device connection, created by DeviceSessionPool (T9). This task builds only the class and tests it over FakeCipChannel.
  • LogixTagClient:
    • ReadAsync(path, elements) sends Read Tag.
      • If the reply status is 0x06 (Partial), it keeps that reply's data and continues with Read Tag Fragmented at offset = bytesReceivedSoFar, appending each reply's data until a reply has status 0x00. That avoids double-counting the first chunk (review finding 15).
      • It returns (TypeCode, StructHandle, byte[] Data). The type code and handle come from the first reply; fragment replies repeat the type header, so strip it from each before appending.
      • Reply-size note: packing bounds only the request size, so many large reads packed together can produce a reply that the controller truncates with 0x06 per service. That is correct behaviour (the fragment loop recovers it), not an error.
    • WriteAsync(path, type, handle, elements, data) sends one Write Tag when it fits, otherwise Write Tag Fragmented chunks of MaxRequestBytes - 12 - path.Length, aligned down to the element width.
    • Non-zero final status ⇒ AbTransportException(reply.Status).
  • FakeCipChannel lets a test set MaxRequestBytes, records every request, and answers via a delegate. It is reused by Tasks 12 and 13.

Verify: dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~RequestScheduler|FullyQualifiedName~LogixTagClient" → pass.

Steps:

  • Step 1: Write FakeCipChannel and the failing tests.
// Fakes/FakeCipChannel.cs
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;

internal sealed class FakeCipChannel(int maxRequestBytes = 504) : ICipChannel
{
    public int MaxRequestBytes { get; set; } = maxRequestBytes;
    public List<byte[]> Sent { get; } = [];
    public Func<byte[], Task<byte[]>> Respond { get; set; } = r => Task.FromResult(new byte[] { (byte)(r[0] | 0x80), 0, 0, 0 });
    public TaskCompletionSource? Gate { get; set; }

    public async Task<ReadOnlyMemory<byte>> SendAsync(ReadOnlyMemory<byte> req, CancellationToken ct)
    {
        lock (Sent) Sent.Add(req.ToArray());
        if (Gate is { } g) await g.Task.WaitAsync(ct);
        return await Respond(req.ToArray());
    }
}

Tests to write (each with explicit byte sizes):

  • Requests_queued_behind_an_inflight_one_are_packed_into_one_msp: hold Gate with the first request in flight, enqueue 3 more, release. Assert Sent.Count == 2 and Sent[1][0] == 0x0A.

  • Msp_exactly_at_limit_packs / Msp_one_byte_over_limit_splits.

  • Packing_disabled_sends_each_bare.

  • Per_service_error_fails_only_its_waiter: the MSP reply carries replies with status 0 and status 0x04. Waiter 1 gets Ok, waiter 2 gets a CipReply with GeneralStatus == 0x04.

  • Channel_failure_faults_every_waiter_in_the_batch.

  • Queued_waiter_cancelled_before_send_is_never_sent.

  • Fragmented_read_keeps_the_first_partial_and_continues: the first (Read Tag) reply has status 0x06 with 100 data bytes, and the next two are Read Tag Fragmented replies (0x06 with 100 bytes, then 0x00 with 40). The result is 240 bytes, and the fragmented requests were sent at offsets 100 and 200.

  • Waiter_cancellation_never_reaches_the_channel_token: the fake channel records the token it received; assert it is never a waiter's token.

  • Cancellation_registrations_are_released.

  • Fragmented_write_chunks_align_to_element_width.

  • Step 2: Run and confirm failure. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~RequestScheduler|FullyQualifiedName~LogixTagClient" → compile error.

  • Step 3: Implement the scheduler.

// Cip/RequestScheduler.cs
using System.Threading.Channels;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;

public sealed class RequestScheduler : IAsyncDisposable
{
    private sealed class Pending(byte[] request)
    {
        public byte[] Request { get; } = request;
        public TaskCompletionSource<CipReply> Tcs { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
        public CancellationTokenRegistration Registration { get; set; }
    }

    private readonly ICipChannel _channel;
    private readonly bool _allowPacking;
    private readonly LinkedList<Pending> _queue = new();
    private readonly object _lock = new();
    private readonly SemaphoreSlim _signal = new(0);
    private readonly CancellationTokenSource _stop = new();
    private readonly Task _pump;

    public RequestScheduler(ICipChannel channel, bool allowPacking)
    { _channel = channel; _allowPacking = allowPacking; _pump = Task.Run(PumpAsync); }

    public Task<CipReply> SendAsync(byte[] request, CancellationToken ct)
    {
        var p = new Pending(request);
        LinkedListNode<Pending> node;
        lock (_lock) node = _queue.AddLast(p);
        if (ct.CanBeCanceled)
        {
            p.Registration = ct.Register(() =>
            {
                lock (_lock) { if (node.List is not null) _queue.Remove(node); }
                p.Tcs.TrySetCanceled(ct);
            });
            // Release the registration as soon as the request completes, however it completes (finding 10).
            _ = p.Tcs.Task.ContinueWith(static (_, state) => ((Pending)state!).Registration.Dispose(), p,
                CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
        }
        _signal.Release();
        return p.Tcs.Task;
    }

    private List<Pending> TakeBatch()
    {
        var batch = new List<Pending>();
        lock (_lock)
        {
            var sizes = new List<int>();
            while (_queue.First is { } n)
            {
                if (batch.Count > 0 && !_allowPacking) break;
                sizes.Add(n.Value.Request.Length);
                if (batch.Count > 0 && MultipleServicePacket.BuildSize(sizes) > _channel.MaxRequestBytes) break;
                batch.Add(n.Value);
                _queue.RemoveFirst();
            }
        }
        return batch;
    }

    private async Task PumpAsync()
    {
        while (!_stop.IsCancellationRequested)
        {
            try { await _signal.WaitAsync(_stop.Token).ConfigureAwait(false); } catch (OperationCanceledException) { return; }
            var batch = TakeBatch();
            if (batch.Count == 0) continue;
            try
            {
                if (batch.Count == 1)
                {
                    var raw = await _channel.SendAsync(batch[0].Request, _stop.Token).ConfigureAwait(false); // never a waiter's token (B2)
                    batch[0].Tcs.TrySetResult(CipReply.Parse(raw));
                }
                else
                {
                    var raw = await _channel.SendAsync(MultipleServicePacket.Build(batch.Select(b => b.Request).ToList()), _stop.Token).ConfigureAwait(false);
                    var envelope = CipReply.Parse(raw);
                    if (envelope.GeneralStatus is not (0x00 or 0x1E))
                        throw new AbTransportException(envelope.Status, $"MSP failed: 0x{envelope.GeneralStatus:X2}.");
                    var replies = MultipleServicePacket.Parse(envelope, batch.Count);
                    for (var i = 0; i < batch.Count; i++) batch[i].Tcs.TrySetResult(replies[i]);
                }
            }
            catch (Exception ex)
            {
                foreach (var b in batch) b.Tcs.TrySetException(ex);
            }
            // drain remaining signals for items taken in this batch
            for (var i = 1; i < batch.Count; i++) _signal.Wait(0);
        }
    }

    public async ValueTask DisposeAsync()
    {
        _stop.Cancel();
        try { await _pump.ConfigureAwait(false); } catch { /* stopping */ }
        lock (_lock) foreach (var p in _queue) p.Tcs.TrySetCanceled();
        _signal.Dispose(); _stop.Dispose();
    }
}

The requirement "requests queued while one is in flight are packed" falls out of the single pump: the pump is busy awaiting the channel, so requests accumulate until it loops back. Do not add a delay-based batching window.

  • Step 4: Implement LogixTagClient to the acceptance criteria. It holds a RequestScheduler and an ICipChannel (for MaxRequestBytes), and builds requests with LogixServices.
  • Step 5: Run. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests → all pass, then run the RequestScheduler tests 20× to check they are not flaky.
  • Step 6: Commit. git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests && git commit -m "feat(ab-transport): MSP-coalescing request scheduler and fragmented Logix read/write"

Task 11: libplctag-confinement guard

Goal: A source-scan test that fails if any production .cs file outside the libplctag adapters references libplctag, so the eventual deletion stays mechanical.

Files:

  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/LibplctagConfinementGuardTests.cs

Acceptance Criteria:

  • The test walks up from AppContext.BaseDirectory to the directory containing ZB.MOM.WW.OtOpcUa.slnx, then scans every *.cs under src/ (excluding bin/obj) for code references: using libplctag or libplctag.(Tag|Status|PlcType|Protocol|LibPlcTagException|NativeImport). Prose such as the CLI help string "via libplctag. Second of six…" (AbCip.Cli/Program.cs:9, AbLegacy.Cli/Program.cs:8) must not match, or the guard is red from wave 2 until T15/T16 (review finding 6).
  • Allowed files: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Libplctag*.cs and src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/Libplctag*.cs only. Comments and XML docs are ignored by stripping // line comments before matching.
  • The failure message lists each offending file and line.
  • A second fact, Prose_mentions_of_libplctag_do_not_trip_the_guard, runs the regex against the literal "…via libplctag. Second of six driver CLIs" and asserts no match.
  • Falsifiability check: temporarily add using libplctag; to src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs, run the test and see it go red, then revert. Record that you did this in the commit message.

Verify: dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter LibplctagConfinementGuard → pass.

Steps:

  • Step 1: Write the test.
using System.Text.RegularExpressions;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests;

public sealed class LibplctagConfinementGuardTests
{
    private static readonly Regex Reference = new(
        @"\busing\s+libplctag\b|\blibplctag\.(Tag|Status|PlcType|Protocol|LibPlcTagException|NativeImport)\b",
        RegexOptions.Compiled);

    [Fact]
    public void Only_the_libplctag_adapters_reference_libplctag()
    {
        var root = new DirectoryInfo(AppContext.BaseDirectory);
        while (root is not null && !File.Exists(Path.Combine(root.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) root = root.Parent;
        root.ShouldNotBeNull("could not find the repo root");

        var offenders = new List<string>();
        foreach (var file in Directory.EnumerateFiles(Path.Combine(root.FullName, "src"), "*.cs", SearchOption.AllDirectories))
        {
            if (file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}") ||
                file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")) continue;
            var name = Path.GetFileName(file);
            var dir = Path.GetFileName(Path.GetDirectoryName(file)!);
            var allowed = name.StartsWith("Libplctag", StringComparison.Ordinal)
                && dir is "ZB.MOM.WW.OtOpcUa.Driver.AbCip" or "ZB.MOM.WW.OtOpcUa.Driver.AbLegacy";
            if (allowed) continue;
            var lines = File.ReadAllLines(file);
            for (var i = 0; i < lines.Length; i++)
            {
                var code = lines[i].Split("//")[0];
                if (Reference.IsMatch(code)) offenders.Add($"{Path.GetRelativePath(root.FullName, file)}:{i + 1}: {lines[i].Trim()}");
            }
        }
        offenders.ShouldBeEmpty(string.Join(Environment.NewLine, offenders));
    }
}
  • Step 2: Run it (it should pass now that T2 and T3 have merged). Then do the falsifiability check from the acceptance criteria.
  • Step 3: Commit. git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/LibplctagConfinementGuardTests.cs && git commit -m "test(ab-transport): guard libplctag confinement to its adapters (falsified by adding using libplctag to AbCipDriver.cs)"

Task 12: NativeAbCipTagRuntime + factory

Goal: A native IAbCipTagRuntime that leases the device connection from DeviceSessionPool.Shared, reads and writes through LogixTagClient, decodes the managed buffer with the same semantics as LibplctagTagRuntime, and adds a CIP type check.

Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagFactory.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipDevice.cs (maps AbCipTagCreateParams → DeviceKey + ConnectionOptions; shared with Task 13)
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs (add trailing optionals AbCipDataType? AuthoredType = null, bool UseConnectedMessaging = true to AbCipTagCreateParams)
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs (pass UseConnectedMessaging: profile.SupportsConnectedMessaging)
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs (EnsureTagRuntimeAsync passes AuthoredType: def.DataType; DeviceState's create-params builder passes UseConnectedMessaging: Profile.SupportsConnectedMessaging)
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs
  • Modify: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj (add a ProjectReference to Ab.Transport and <Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\Fakes\FakeCipChannel.cs" Link="Fakes\FakeCipChannel.cs" />. Source-linking compiles the fake into this assembly, so it stays internal. Also add InternalsVisibleTo for ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests to the Ab.Transport csproj if the tests touch internal transport types)

Acceptance Criteria:

  • NativeAbCipDevice.Key(p) = new DeviceKey(p.Gateway, p.Port > 0 ? p.Port : 44818, p.CipPath, p.LibplctagPlcAttribute).
  • NativeAbCipDevice.Options(p):
    • Timeout = p.Timeout, ConnectionSize = p.ConnectionSize, AllowPacking = p.AllowPacking.
    • Routing: if p.UseConnectedMessaging is false, use UnconnectedSend when p.CipPath is non-empty and Direct when it is empty. Otherwise use Connected.
    • UseConnectedMessaging is a new trailing optional bool UseConnectedMessaging = true on AbCipTagCreateParams. It is set at every construction site from AbCipPlcFamilyProfile.SupportsConnectedMessaging, so the family profile stays the single authority and string-matching "micro800" is not needed (review finding 18). The sites are AbCipDriver DeviceState (around AbCipDriver.cs:1690-1700, where AllowPacking/ConnectionSize are already merged) and AbCipDriverProbe; find any others with grep -rn "new AbCipTagCreateParams" src.
    • Cross-check the result against the Task 4 "Routing observed" table for controllogix and micro800, and record any mismatch in the test comment.
  • Initialization and reads:
    • InitializeAsync acquires the lease from DeviceSessionPool.Shared and builds a per-runtime LogixTagClient over the lease's shared Scheduler and Channel. It must never construct its own RequestScheduler: that would defeat MSP coalescing across tags (B1). It encodes the path once, splitting off a trailing bit selector with CipPath.SplitBitSelector; that is stored as the runtime's implicit bit.
    • It performs one read to prove the tag exists. That matches libplctag, whose InitializeAsync errors for an unknown tag; the driver's 05/STAB-8 logic depends on create throwing NotFound.
    • ReadAsync reads p.ElementCount elements, stores the data, TypeCode and StructHandle, and sets status Ok. On AbTransportException it stores the exception's status and rethrows, matching libplctag's exception-on-failure behaviour. GetSize() returns the data length.
  • Decode is byte-identical to LibplctagTagRuntime for every AbCipDataType (Bool/SInt/USInt/Int/UInt/DInt/UDInt/LInt/ULInt/Real/LReal/String/Dt). Specifically:
    • Bool with a bit index ⇒ bit n of the little-endian buffer.
    • Bool without ⇒ byte[0] != 0.
    • String ⇒ Logix STRING u32 LEN + DATA, ASCII, LEN clamped to the data length − 4.
    • DecodeArray uses stride = data.Length / count when it divides evenly, else the static width (the same ResolveArrayStride rule; call LibplctagTagRuntime.ResolveArrayStride with elementSize: null to share it).
    • Bool arrays are bit-packed (bit i of the buffer).
  • Type check: after a read, if TypeCode is atomic and differs from the authored type's Logix code, GetStatus() returns (int)AbStatus.TypeMismatch and Decode* returns null. The mapping is AbCipDataType → LogixTypeCode, with Dt → DInt; String/Structure are exempt. Exemptions:
    • (a) A BOOL read with a bit index against a DINT host is not a mismatch.
    • (b) Authored Bool with returned 0xD3 (the Logix packed BOOL-array type) is not a mismatch (review finding 11).
    • Tests cover both exemptions, plus a positive mismatch.
  • Encode/write:
    • EncodeValue fills a managed write buffer with the same encodings as LibplctagTagRuntime.EncodeValue, including the same NotSupportedException cases.
    • WriteAsync writes with type = the last read TypeCode if known, else the authored type's code.
    • For String, it uses Struct + the last StructHandle. If none is known it performs a read first; that read happens inside WriteAsync, under the driver's existing per-runtime lock.
    • The STRING write payload is u32 len + DATA padded to the capacity learned from the last read's data length − 4.
  • Dispose releases the lease, waiting for at most 2 s (lease.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(2))). It must not fire and forget: an immediate re-init would otherwise open a second session to the press while the old Forward Close is still in flight (review finding 22). It never disposes the shared scheduler; the pool does that on the last release.
  • NativeAbCipTagFactory : IAbCipTagFactory creates NativeAbCipTagRuntime.
  • Test seam: internal static NativeAbCipTagRuntime ForTest(ICipChannel channel, AbCipTagCreateParams p) builds a runtime over the given channel with a private RequestScheduler(channel, p.AllowPacking) (test-only; no pool lease). Production code always goes through the factory and DeviceSessionPool.Shared.
  • Unit tests (against FakeCipChannel injected through an internal constructor that takes an ICipChannel):
    • decode parity for each type, using a buffer captured from LibplctagTagRuntime semantics (hard-coded bytes);
    • the TypeMismatch case (REAL returned for a DInt-authored tag);
    • an unknown tag ⇒ InitializeAsync throws AbTransportException(NotFound);
    • a STRING write sends Struct + the handle learned from the prior read;
    • a 1-element array is read as an array (IsArray).

Verify: dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter NativeAbCipTagRuntime → pass.

Steps:

  • Step 1: Write the failing tests, for example:
[Fact]
public async Task Real_returned_for_a_DInt_authored_tag_is_TypeMismatch()
{
    var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("CC000000" + "CA00" + "0000803F")) };
    using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Speed", AbCipDataType.DInt));
    await rt.InitializeAsync(CancellationToken.None);
    await rt.ReadAsync(CancellationToken.None);
    rt.GetStatus().ShouldBe((int)AbStatus.TypeMismatch);
    rt.DecodeValue(AbCipDataType.DInt, null).ShouldBeNull();
}

[Fact]
public async Task Unknown_tag_fails_initialize_with_NotFound()
{
    var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("CC000400")) };
    using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Nope", AbCipDataType.DInt));
    (await Should.ThrowAsync<AbTransportException>(() => rt.InitializeAsync(CancellationToken.None))).Status.ShouldBe(AbStatus.NotFound);
}

AbCipTagCreateParams has no authored-type field. Add an optional AbCipDataType? AuthoredType = null parameter at the end of the record (it is binary compatible for every existing named-argument caller). Set it at AbCipDriver.cs EnsureTagRuntimeAsync from def.DataType. The libplctag runtime ignores it.

  • Step 2: Run and confirm failure. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter NativeAbCipTagRuntime → compile error.
  • Step 3: Implement to the acceptance criteria. Use BinaryPrimitives for every decode, never BitConverter.
  • Step 4: Run. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests → all pass, including every pre-existing test.
  • Step 5: Commit. git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests && git commit -m "feat(abcip): native tag runtime over the managed CIP transport, with returned-type checking"

Task 13: Native AbCip symbol enumerator + template reader

Goal: Native IAbCipTagEnumerator and IAbCipTemplateReader implementations whose output buffers feed the unchanged decoders.

Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/SymbolListingEnumerator.cs (the transport-neutral listing loop moved out of LibplctagTagEnumerator.cs)
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs (keeps only LibplctagListingReader + LibplctagTagEnumeratorFactory)
  • Modify: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ files referencing LibplctagTagEnumerator (rename to SymbolListingEnumerator)
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagEnumerator.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTemplateReader.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagEnumeratorTests.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTemplateReaderTests.cs

Acceptance Criteria:

  • NativeAbCipTagEnumerator reuses the LibplctagTagEnumerator(Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> readListing) seam. Its listing reader:
    • "@tags" ⇒ pages SymbolObjectServices.GetInstanceAttributeList(null, start) from instance 0, appending entry bytes. While the reply is More, it continues from LastInstance + 1.
    • "Program:X.@tags" ⇒ the same with programName: "X".
    • It returns the concatenated bytes.
    • So NativeAbCipTagEnumerator is new LibplctagTagEnumerator(NativeListingReader.ReadAsync), wrapped in a class for the factory. Rename LibplctagTagEnumerator → SymbolListingEnumerator (it has no libplctag dependency apart from its default constructor). Move the libplctag default reader into LibplctagTagEnumerator.cs as LibplctagListingReader, so the guard (Task 11) still allows it.
  • NativeAbCipTemplateReader.ReadAsync(params, id):
    • GetAttributes(id) gives defWords, structBytes, members, handle.
    • It then sends ReadTemplate(id, offset, chunk) repeatedly, with chunk = Math.Min(remaining, MaxRequestBytes - 16), where the total = defWords * 4 - 23 (the CIP-documented Read Template length), advancing by each reply's data length until the status is 0. Status 0x06 continues.
    • It returns TemplateObjectServices.BuildDecoderBuffer(...).
  • Unit tests feed the real controller captures through the native path. The fake channel serves, as reply data, the bytes contained in CipTemplateObjectWireCaptureTests (rev 34.11 press). The test asserts that CipTemplateObjectDecoder.Decode(nativeBuffer) equals Decode(capturedLibplctagBuffer), member by member.
    • The captured libplctag buffer's header is the 14-byte header, so the fake replies are built from that header's fields plus the raw bytes after byte 14.
    • Do the same for CipSymbolObjectRealControllerCaptureTests entries: split them across two pages with a 0x06 status between, and assert the decoded tag list is identical.
  • Both factories (NativeAbCipTagEnumeratorFactory, NativeAbCipTemplateReaderFactory) exist and lease from DeviceSessionPool.Shared via NativeAbCipDevice. They send through the lease's shared Scheduler, never a private one, and dispose the lease (bounded 2 s wait, as in T12) when the enumeration or read completes.

Verify: dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter "FullyQualifiedName~NativeAbCipTagEnumerator|FullyQualifiedName~NativeAbCipTemplateReader|FullyQualifiedName~Discovery" → pass.

Steps:

  • Step 1: Write the failing tests that reuse the real-capture byte arrays. Reference them through the existing test classes' fields if those are accessible; otherwise copy the hex into a shared RealControllerCaptures.cs and point both old and new tests at it.
  • Step 2: Run and confirm failure. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter "FullyQualifiedName~NativeAbCipTagEnumerator|FullyQualifiedName~NativeAbCipTemplateReader" → compile error.
  • Step 3: Split the enumerator. Move the class body into SymbolListingEnumerator.cs as SymbolListingEnumerator, leaving LibplctagListingReader.ReadAsync and LibplctagTagEnumeratorFactory (which now returns new SymbolListingEnumerator(LibplctagListingReader.ReadAsync)) in LibplctagTagEnumerator.cs. Then implement the native readers.
  • Step 4: Run the full AbCip.Tests suite → all pass.
  • Step 5: Commit. git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests && git commit -m "feat(abcip): native symbol listing and template reads feeding the unchanged decoders"

Task 14: NativeAbLegacyTagRuntime + factory

Goal: A native IAbLegacyTagRuntime over PCCC (Task 8) carried in Execute PCCC through a DeviceSessionPool lease.

Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagFactory.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs
  • Modify: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj (reference Ab.Transport + link FakeCipChannel.cs, as in Task 12)

Acceptance Criteria:

  • Connection:
    • DeviceKey is built from AbLegacyTagCreateParams the same way as Task 12 (default port 44818).
    • Routing comes from the Task 4 "Routing observed" table (B3), per family:
      • Expected: slc500/micrologix/plc5 ⇒ Direct (libplctag sends Execute PCCC bare in SendRRData even though the driver forces EffectiveCipPath = "1,0", AbLegacyDriver.cs:864); logixpccc ⇒ Connected.
      • If the capture shows otherwise, the capture wins. Encode the choice in one static CipRouting RoutingFor(string plcAttribute) with a comment citing the capture file, and pin it with a test per family.
    • ConnectionSize = 504, AllowPacking = false (PCCC requests are never MSP-packed).
    • Send through the lease's shared Scheduler, never a private one (B1).
  • Address handling:
    • AbLegacyAddress.TryParse(p.TagName) ⇒ PcccAddress(FileLetter, FileNumber, WordNumber, sub). The sub-element comes from ResolveSubElement. A BitIndex is kept for decode only, because reads are word reads.
    • Byte counts per element: N/A/B/I/O/S = 2, F/L = 4, T/C/R = 6 when no sub-element (the whole element), 2 when a word sub-element is chosen, ST = 84.
    • Element count = p.ElementCount.
  • Read and write:
    • ReadAsync dispatches by plc attribute: slc500/micrologix/logixpccc ⇒ SlcTypedRead; plc5 ⇒ Plc5TypedRead, or AbTransportException(Unsupported) if Task 8 marked PLC-5 unverified.
    • The TNS increments per request.
    • Chunking (review finding 12): a read or write whose byte count exceeds PcccServices.MaxDataBytes (236) is split into consecutive element ranges of at most 236 bytes (118 words, or 59 longs/floats). The element number advances per chunk and the data is concatenated in order. A failure in any chunk fails the whole operation with that chunk's status; a partial write is reported as failed, never as Good. Test a 256-word array read (three chunks: 118 + 118 + 20) and a 130-word write (two chunks).
    • On failure it stores the status and rethrows.
    • InitializeAsync performs one read, for the same NotFound-on-create reason as Task 12.
  • Decode is identical to LibplctagLegacyTagRuntime:
    • Bit with index ⇒ bit of the LE word (/N up to 15, or up to 31 for L).
    • Bit without ⇒ (word & 1) != 0.
    • Int/AnalogInt ⇒ short→int; Long ⇒ int; Float ⇒ float.
    • String ⇒ the SLC ST layout: u16 LEN + 82 data bytes with each byte pair swapped. ⚠️ Verify this byte order against slc500 ST data in ab_server. If ab_server does not seed ST, test against a hand-built buffer and flag it in Task 20 docs as unverified.
    • Timer/Counter/Control element without a sub-element ⇒ GetInt32(0) parity (the first 4 bytes, LE).
    • DecodeArray uses the offsets documented in LibplctagLegacyTagRuntime.DecodeArray.
  • Encode follows LibplctagLegacyTagRuntime.EncodeValue case by case, with the same NotSupportedException for bit-with-index. WriteAsync ⇒ SlcTypedWrite (or PLC-5 / Unsupported).
  • Unit tests:
    • N7:0 read decode;
    • F8 read;
    • B3:0/5 decode;
    • an N7 array of 5;
    • T4:0.ACC sub-element addressing (bytes on the wire);
    • STS 0x20 ⇒ NotFound on initialize;
    • a write of N7:0 = 42 produces the expected PCCC bytes.

Verify: dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter NativeAbLegacyTagRuntime → pass.

Steps:

  • Step 1: Write the failing tests against FakeCipChannel. Build replies with this helper:
private static byte[] PcccReply(ushort tns, byte[] data) =>
    [0xCB, 0x00, 0x00, 0x00, 0x07, 0x37, 0x13, 0x00, 0x00, 0x00, 0x00, 0x4F, 0x00, (byte)tns, (byte)(tns >> 8), .. data];

Note the leading 0xCB (0x4B | 0x80) and the 7-byte requestor-id echo. Adjust it to whatever echo the Task 4 capture shows.

  • Step 2: Run and confirm failure. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter NativeAbLegacyTagRuntime → compile error.
  • Step 3: Implement to the acceptance criteria.
  • Step 4: Run the full AbLegacy.Tests suite → all pass.
  • Step 5: Commit. git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests && git commit -m "feat(ablegacy): native PCCC tag runtime over the managed transport"

Task 15: AbCip Transport switch (options, factory, probe, CLI)

Goal: One Transport option selects the native or libplctag factory set everywhere AbCip creates a transport object: the driver, Test Connect, and the CLI.

Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTransport.cs
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs (add public AbCipTransport Transport { get; init; } = AbCipTransport.Libplctag;)
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs (DTO string? Transport; ParseEnum<AbCipTransport>(dto.Transport, …, fallback: AbCipTransport.Libplctag))
  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTransportFactories.cs
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:145-155 (null factories ⇒ AbCipTransportFactories.For(options.Transport))
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs (use AbCipTransportFactories.For(opts.Transport).Tags)
  • Modify: src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/ (--transport native|libplctag, default libplctag; help text "via libplctag" → "via libplctag or the native transport")
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs
  • Modify: tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/ (add a --transport parse test)

Acceptance Criteria:

  • public enum AbCipTransport { Libplctag, Native }.
  • AbCipTransportFactories.For(t) returns (IAbCipTagFactory Tags, IAbCipTagEnumeratorFactory Enumerators, IAbCipTemplateReaderFactory Templates): the native trio for Native, the libplctag trio for Libplctag.
  • Explicitly injected factories (tests) still win over the option.
  • JSON configs: "transport": "Native" ⇒ Native; "transport": "native" ⇒ Native (case-insensitive, as with the existing ParseEnum); absent ⇒ Libplctag; "transport": "Bogus" ⇒ the same failure the existing ParseEnum raises for a bad PlcFamily (do not invent a new behaviour).
  • Reinit does NOT swap transports, by design (review finding 19). AbCipDriver.ReinitializeAsync (AbCipDriver.cs:401) is ShutdownAsync + InitializeAsync over constructor-held factories, so a transport change in the reinit JSON is inert. Do not invent factory swapping:
    • Document in the option's XML doc and in docs/drivers/AbCip.md (T20) that changing transport takes effect when the driver instance is recreated, which a deploy that changes the driver config does.
    • Pin the current behaviour with a test, Reinit_with_a_changed_transport_keeps_the_constructed_transport, so a future change is deliberate.
    • Before writing that test, confirm how a deploy applies a changed DriverConfig: grep -rn "ReinitializeAsync\|CreateInstance" src/Server src/Core | grep -i driver. If deploys recreate the instance, say so in the doc. If they call ReinitializeAsync, raise it as a follow-up in the commit message rather than widening this task.
  • The probe deserializes with its own JsonStringEnumConverter, so a bad "transport" reports "Config JSON is invalid", not the ParseEnum message. That is acceptable; assert that exact behaviour in the probe test rather than the driver's.
  • The probe with Transport: Native constructs a NativeAbCipTagRuntime. Prove it with a test that uses an unreachable 127.0.0.1:1 endpoint and asserts the failure message comes from the native path ("TCP connect … failed"), not libplctag's.
  • The CLI --transport native reaches AbCipDriverOptions.Transport.

Verify: dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter AbCipTransportSwitch && dotnet test tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests → pass.

Steps:

  • Step 1: Write the failing tests (option parsing, factory selection, injected-wins, probe path).
  • Step 2: Run and confirm failure. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter AbCipTransportSwitch → compile error.
  • Step 3: Implement.
// AbCipTransportFactories.cs
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;

internal static class AbCipTransportFactories
{
    public static (IAbCipTagFactory Tags, IAbCipTagEnumeratorFactory Enumerators, IAbCipTemplateReaderFactory Templates)
        For(AbCipTransport transport) => transport switch
    {
        AbCipTransport.Native => (new NativeAbCipTagFactory(), new NativeAbCipTagEnumeratorFactory(), new NativeAbCipTemplateReaderFactory()),
        _ => (new LibplctagTagFactory(), new LibplctagTagEnumeratorFactory(), new LibplctagTemplateReaderFactory()),
    };
}

In the AbCipDriver constructor:

var defaults = AbCipTransportFactories.For(options.Transport);
_tagFactory = tagFactory ?? defaults.Tags;
_enumeratorFactory = enumeratorFactory ?? defaults.Enumerators;
_templateReaderFactory = templateReaderFactory ?? defaults.Templates;
  • Step 4: Run the AbCip and CLI test suites → all pass.
  • Step 5: Commit. git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests && git commit -m "feat(abcip): Transport option selects native or libplctag across driver, probe and CLI"

Task 16: AbLegacy Transport switch (options, factory, probe, CLI)

Goal: Do the same as Task 15 for AbLegacy.

Files:

  • Create: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTransport.cs (public enum AbLegacyTransport { Libplctag, Native })
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs (Transport property, default Libplctag)
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs (DTO + parse)
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs:65-71 (_tagFactory = tagFactory ?? (options.Transport == AbLegacyTransport.Native ? new NativeAbLegacyTagFactory() : new LibplctagLegacyTagFactory());)
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs
  • Modify: src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/ (--transport)
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs
  • Modify: tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/

Acceptance Criteria: the same five criteria as Task 15, applied to AbLegacy types:

  • "transport": "Native" and "native" select the native factory, absent selects libplctag, and a bad value fails the way an existing bad enum field does.
  • An injected factory wins over the option.
  • The probe with Native uses the native path; prove it with the unreachable-endpoint message test.
  • The CLI --transport native reaches the options.
  • A reinit with a changed transport keeps the constructed transport (AbLegacyDriver reinit has the same Shutdown+Initialize shape), pinned by the same kind of test and documentation as Task 15.

Verify: dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter AbLegacyTransportSwitch && dotnet test tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests → pass.

Steps:

  • Step 1: Write the failing tests: option parsing ("Native", "native", absent, "Bogus"), injected-factory-wins, the probe's native-path message for 127.0.0.1:1, and the CLI --transport native parse.
  • Step 2: Run and confirm failure. dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter AbLegacyTransportSwitch → compile error.
  • Step 3: Implement.
    • Add the enum, the options property and the DTO field. Parse it with the file's existing enum-parse helper, falling back to AbLegacyTransport.Libplctag.
    • Use the constructor line given under Files.
    • In the probe, build the runtime with options.Transport == AbLegacyTransport.Native ? new NativeAbLegacyTagFactory() : new LibplctagLegacyTagFactory() (or the injected factory from Task 3).
    • Add --transport native|libplctag to the CLI, defaulting to libplctag, and change its "via libplctag" help text to "via libplctag or the native transport".
  • Step 4: Run the AbLegacy and CLI test suites → all pass.
  • Step 5: Commit. git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests && git commit -m "feat(ablegacy): Transport option selects native or libplctag across driver, probe and CLI"

Task 17: AbCip + AbLegacy integration suites on both transports

Goal: Every existing ab_server integration test runs once per transport, and the native run is green wherever the libplctag run is green, against every compose profile.

Files:

  • Modify: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs and every other test in that project that constructs AbCipDriverOptions (grep -rln "new AbCipDriverOptions" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests)
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs
  • Modify: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs and siblings
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs
  • Create: docs/plans/2026-10-07-native-ab-transport-gate-results.md (results table; Tasks 18 and 21 append to it)

Acceptance Criteria:

  • TransportMatrix.Profiles yields (profile, transport) for every profile × both transports. The theories switch from MemberData(nameof(Profiles)) to MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix)), and options get Transport = transport.
  • The AbLegacy matrix is its own class in the AbLegacy integration project, using AbLegacyTransport, and it preserves the existing AB_LEGACY_COMPOSE_PROFILE filter that AbLegacyReadSmokeTests.Profiles applies today (AbLegacyReadSmokeTests.cs:26-35): it must filter KnownProfiles.All by that env var before crossing with transports. The AbCip snippet below does not apply to AbLegacy verbatim (review finding 20).
  • Run against 10.100.0.35, one compose profile at a time:
    • AbCip: controllogix, compactlogix, micro800, guardlogix.
    • AbLegacy: slc500, micrologix, plc5.
    • For each profile, the set of passing tests under Native ⊇ the set under Libplctag.
  • Any native-only failure is fixed in the transport task's code (reopen that task's files) and is not skipped. The only allowed exception is the PLC-5 Unsupported path from Task 8, marked Skip = "PLC-5 framing unverified — see Task 8" only if Task 8 marked it.
  • The results doc records, under its ## ab_server suites (T17) heading, per profile × transport, the pass/fail/skip counts plus the ab_server image tag and the commit.

Verify: for each profile P: ssh dohertj2@10.100.0.35 'cd /opt/otopcua-abcip && docker compose --profile P up -d' then AB_SERVER_ENDPOINT=10.100.0.35:44818 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --logger "trx;LogFileName=P.trx" → the Native pass set ⊇ the Libplctag pass set (compare the two trx files with grep -o 'testName="[^"]*" .*outcome="[^"]*"').

Steps:

  • Step 1: Add TransportMatrix and retarget every theory.
public static class TransportMatrix
{
    public static IEnumerable<object[]> Profiles =>
        from p in KnownProfiles.All
        from t in new[] { AbCipTransport.Libplctag, AbCipTransport.Native }
        select new object[] { p, t };
}
  • Step 2: Run the suites per profile as in Verify. AbLegacy uses its own Docker dir and its fixture's endpoint env var (read AbLegacyServerFixture.cs for the name).
  • Step 3: Triage every Native-only failure. Reproduce it with the CLI (--transport native vs --transport libplctag), compare against the Task 4 capture, fix the code, add a unit test that pins the fix in the owning task's test file, and re-run.
  • Step 4: Write the results table and tear down the fixtures.
  • Step 5: Commit. git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests docs/plans/2026-10-07-native-ab-transport-gate-results.md && git commit -m "test(ab): run the ab_server integration suites on both transports"

Task 18: Differential harness (libplctag vs native, same target)

Goal: A LiveIntegration test that reads the same tags through both transports against one endpoint and asserts equal raw buffers and equal decoded values. It skips cleanly when the endpoint env var is absent.

Files:

  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialTarget.cs
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs and NativeAbCipTagRuntime.cs (add internal ReadOnlyMemory<byte> RawBuffer)
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj (InternalsVisibleTo for ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests)

Acceptance Criteria:

  • Env gating:
    • AB_DIFF_ENDPOINT (host:port), AB_DIFF_PATH (default 1,0), AB_DIFF_FAMILY (default CompactLogix).
    • AB_DIFF_TAGS: an optional comma list. Absent ⇒ every atomic scalar, the first 20 arrays and the first 20 UDT members from browse.
    • AB_DIFF_MAX_TAGS (default 200).
    • Absent AB_DIFF_ENDPOINT ⇒ every test reports skipped, with the reason naming the variable.
  • The suite is read-only by construction. It never calls WriteAsync or EncodeValue. A guard test reflects over TransportDifferentialTests and fails if any method body references WriteAsync (scan its source file).
  • Browse parity: @tags plus every program listing through both enumerators decode to identical AbCipDiscoveredTag sets (order-insensitive). Every TemplateInstanceId seen decodes to an identical AbCipUdtShape through both template readers.
  • Read parity: for each selected tag, under both transports:
    • Run InitializeAsync + ReadAsync.
    • Assert GetStatus() is equal, except that native TypeMismatch where libplctag says Ok is reported as a distinct expected divergence (spec §5), listed in the output and not failed.
    • Assert the raw buffers are equal. To expose the raw bytes, add internal ReadOnlyMemory<byte> RawBuffer to both runtimes: libplctag's via _tag.GetBuffer(), native's via its stored data. Add InternalsVisibleTo for the integration test assembly in the AbCip csproj.
    • Assert the decoded values are equal.
  • Connection discipline (review finding 9):
    • Before any native work, the harness acquires one DeviceSessionPool.Shared.Acquire(key, options) anchor lease for the target, and holds it until the run ends. Per-tag native runtimes therefore share that one connection: the pool never reaches refcount 0, so it never sends Forward Close and re-opens between tags.
    • libplctag runtimes and native runtimes are never alive at the same time. All libplctag reads run first, and their handles are disposed. Then all native reads run. So the press sees at most two sessions over the run, never concurrently.
    • With AB_DIFF_COUNT_OPENS=1, the native side asserts it made exactly one Forward Open over the whole run, using CipConnection.ForwardOpenCount from Task 9 (reset it at the start of the run).
  • Output: a markdown table (tag, type, libplctag status, native status, equal?) written to TestContext.Current.TestOutputHelper.
  • Results are appended under the gate-results doc's ## Differential vs ab_server (T18) heading only.
  • Run against ab_server (controllogix profile) with AB_DIFF_ENDPOINT=10.100.0.35:44818 ⇒ zero unexpected divergences. Append the results to the gate-results doc.
  • Ordering: blocked by Task 17, so any transport fixes found by the ab_server suites land before the differential run.

Verify: AB_DIFF_ENDPOINT=10.100.0.35:44818 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --filter "FullyQualifiedName~Differential" → pass with 0 unexpected divergences; without the env var → all skipped.

Steps:

  • Step 1: Write the harness. Use the xUnit v3 Assert.Skip(...) call (the project is on xunit.v3), or a dynamic-skip attribute pattern matching AbServerTheoryAttribute.
  • Step 2: Run against ab_server, fix any divergence (in the owning task's code, with a pinning unit test), and re-run until clean.
  • Step 3: Commit. git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip docs/plans/2026-10-07-native-ab-transport-gate-results.md && git commit -m "test(abcip): read-only differential harness comparing native and libplctag on one target"

Task 19: AdminUI Transport dropdown (AbCip + AbLegacy) + docker-dev /run

Goal: An operator can pick the transport per driver in the /raw driver-config modal, the value round-trips as a string, and a live docker-dev run proves it reaches the driver.

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor (FormModel.Transport, ToOptions/FromOptions, an InputSelect in the advanced section)
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor (same)
  • Modify: tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs (add Transport round-trip cases)
  • Create or modify: tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs (exists ⇒ add cases; absent ⇒ create, modelled on the AbLegacy file)

Acceptance Criteria:

  • The dropdown has options "libplctag (default)" ⇒ Libplctag and "Native (managed)" ⇒ Native, bound with @bind-Value:after="EmitAsync" the way the other fields are.
  • The serialized JSON contains "transport":"Native" (a string, not a number). It goes through the existing _jsonOpts with JsonStringEnumConverter (AbCipDriverForm.razor:108-113); verify the AbLegacy form has the same converter and add it if missing.
  • Loading a config without transport shows "libplctag (default)" and re-emits without a transport key, or with "Libplctag"; either is fine, but pick one and test it.
  • Live /run on docker-dev (login is disabled; drive it yourself with the Chrome tools against http://localhost:9200):
    1. Bring up the AbCip controllogix fixture on 10.100.0.35.
    2. In /raw, create or edit an AbCip driver with a device ab://10.100.0.35:44818/1,0, set Transport = Native, and add the raw tag TestDINT.
    3. Deploy.
    4. Read the UNS/raw node with the Client CLI (dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=<raw ns>;s=<RawPath>") and get Good.
    5. Confirm from the central node's logs that the native runtime served it. Add one LogDebug("AbCip transport={Transport}") at driver init if no log line distinguishes the two.
    6. Flip back to libplctag, redeploy, and read Good again.
    7. Record the output under the gate-results doc's ## AdminUI /run (T19) heading only.
  • Inverted-gate check (memory: "an unchanged observable proves nothing"): with Transport = Native, point the device at a port where no ab_server is listening. The read must go Bad and the log must show the native error text. That proves the switch changes behaviour and isn't inert.

Verify: AdminUI unit tests pass, plus the docker-dev /run evidence (CLI output for both transports and the inverted case) is pasted into docs/plans/2026-10-07-native-ab-transport-gate-results.md.

Steps:

  • Step 1: Write the failing round-trip test. In the two *DriverPageFormSerializationTests.cs files above, assert ToOptions(FromOptions(x)).Transport == x.Transport for both forms and both values, and that the serialized JSON carries "transport":"Native" as a string. Run it and confirm a compile error.
  • Step 2: Implement both forms. Add the Transport field to each FormModel and to ToOptions/FromOptions, plus the InputSelect in the advanced section, and make sure the AbLegacy form's JSON options include JsonStringEnumConverter. Run the round-trip test → pass.
  • Step 3: Rebuild the docker-dev images with docker-dev/deploy.sh, as docker-dev/README.md documents; do not guess flags.
  • Step 4: Run the live /run sequence above and paste the evidence.
  • Step 5: Commit. git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI tests/Server docs/plans/2026-10-07-native-ab-transport-gate-results.md && git commit -m "feat(adminui): Transport dropdown on AbCip/AbLegacy driver forms (live-verified on docker-dev)"

Task 20: Documentation

Goal: Document the native transport, the switch, the known gaps and the client-visible type-check change, in the repo and in the umbrella history.

Files:

  • Modify: docs/drivers/AbCip.md (sections: "Transport (Transport option)", "Native transport behaviour" covering connection sharing, MSP packing, the type check and press-safety limits, and "Known gaps" covering simulator-only writes, PLC-5/Micro800/routed paths, and libplctag deletion blocked on a bench controller)
  • Modify: docs/drivers/AbLegacy.md (Transport section, PCCC simulator-only gap, PLC-5 status from Task 8, ST byte-order status from Task 14)
  • Modify: docs/drivers/AbServer-Test-Fixture.md and docs/drivers/AbLegacy-Test-Fixture.md (suites now run per transport; the capture recipe scripts/ab-capture/capture.sh)
  • Modify: CLAUDE.md (one short paragraph under a new heading "AB drivers: native transport (Transport)" with the switch, the confinement guard, and a pointer to the docs; keep it short, under 15 lines)
  • Modify: ../scadaproj/.claude/skills/sister-project-history/otopcua.md (append a dated entry), on scadaproj's main per the CLAUDE.md procedure

Acceptance Criteria:

  • docs/drivers/AbCip.md states explicitly:
    • The native path reports BadTypeMismatch for a same-width mis-authored type that libplctag reads as Good garbage, with an example (DINT authored, REAL returned).
    • That is the fix working, and it is called out as client-visible.
  • Every config key added (transport) is documented with its values, its default and string serialization.
  • The scadaproj history entry is committed on main and pushed. The OtOpcUa index row in ../scadaproj/CLAUDE.md is not touched; the summary does not change, since the driver set and wire contract are the same.
  • The scadaproj branch is restored afterwards.

Verify: grep -n "Transport" docs/drivers/AbCip.md docs/drivers/AbLegacy.md CLAUDE.md shows the new sections; cd ../scadaproj && git log origin/main -1 --format=%s shows the history commit.

Steps:

  • Step 1: Write the repo docs listed under Files. Take PLC-5 status from Task 8, ST byte-order status from Task 14, and the /run evidence from the gate-results doc.
  • Step 2: Do the scadaproj edit with the exact CLAUDE.md sequence:
cd ~/Desktop/scadaproj
ORIG=$(git rev-parse --abbrev-ref HEAD); git stash list
git checkout main && git pull
#   append to .claude/skills/sister-project-history/otopcua.md
git commit -am "docs(history): OtOpcUa AbCip/AbLegacy gain a native managed transport behind a Transport switch"
git push origin main
git checkout "$ORIG"
  • Step 3: Commit in OtOpcUa. git add docs/drivers CLAUDE.md && git commit -m "docs(ab): native transport, Transport switch, known gaps and the type-check change"

Task 21: Real-press read-only gate (CR CompactLogix)

Goal: Run the Task 18 differential harness and a latency comparison against one production CompactLogix 5069-L320ERS2 press at CR, read-only, and record the evidence that decides the AbCip default flip.

USER-ORDERED GATE — NON-SKIPPABLE. This task was requested by the user in the current conversation. It MUST NOT be closed by walking around it, by declaring it "verified inline", or by substituting a cheaper check. Close only after every item in acceptanceCriteria has been re-validated independently, with output captured.

Files:

  • Modify: docs/plans/2026-10-07-native-ab-transport-gate-results.md (append the press results)
  • Create: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs

Acceptance Criteria:

  • Preconditions (human): the VPN to CR is up, the user has OK'd touching the press at this time, and the target is one press. The default is Z-35162 at 10.209.70.160:44818, path 1,0; any other press must be named by the user.
  • Run the differential:
    • AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_DIFF_FAMILY=CompactLogix AB_DIFF_COUNT_OPENS=1 dotnet test …--filter "FullyQualifiedName~Differential" passes.
    • Browse parity holds: the same discovered tag set (the 2026-10-07 run saw ~23,412 variables across 80 programs) and identical UDT shapes.
    • Read parity holds over AB_DIFF_MAX_TAGS=200, with zero unexpected divergences. Every TypeMismatch expected-divergence is listed by tag name.
    • Native made exactly 1 Forward Open over the run. That is achievable only because T18's harness holds an anchor lease for the whole run; if the count is higher, the harness is broken, not the press.
  • Latency comparison (PollLatencyComparisonTests, env-gated by the same variables):
    • Read a fixed set of 100 atomic tags 30 times per transport, sequentially; transports are never concurrent. The native side runs under an anchor lease, as in T18.
    • Native passes issue all 100 reads concurrently (Task.WhenAll), so the shared scheduler can MSP-pack them. libplctag passes do the same; libplctag packs internally.
    • Record the CIP round-trips per pass (native: CipConnection.RequestsSent from Task 9, delta per pass; libplctag: report n/a) and the p50/p95 wall time per pass.
    • Pass criterion: native p95 ≤ libplctag p95 × 1.10.
  • No writes:
    • Evidence: the Task 18 source guard passes.
    • grep -c WriteAsync over both differential test files returns 0.
  • Recorded results: under the ## Real press (T21) and ## Latency (T21) headings, the results doc carries the raw dotnet test output, the divergence table, the latency table, the Forward Open count, the date/time (UTC) of the run and the press ID.
  • Failure handling: if any criterion fails, the task stays open. Fix the code (with a pinning test), re-run the ab_server suites (Task 17) and the press run. Do not relax the criterion.

Verify: AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_DIFF_FAMILY=CompactLogix AB_DIFF_COUNT_OPENS=1 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --filter "FullyQualifiedName~Differential" --logger "console;verbosity=detailed" → passed, 0 unexpected divergences, ForwardOpenCount=1, and native p95 ≤ 1.10 × libplctag p95.

Steps:

  • Step 1: Write PollLatencyComparisonTests (skips without the env vars). Run it against ab_server first to prove it works.
  • Step 2: Ask the user to confirm the VPN, the press and the time window. Do not proceed without an explicit yes.
  • Step 3: Run the differential, then the latency comparison, sequentially.
  • Step 4: Paste the evidence into the results doc.
  • Step 5: Commit. git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential docs/plans/2026-10-07-native-ab-transport-gate-results.md && git commit -m "test(abcip): real-press read-only gate — native vs libplctag differential and latency (Z-35162)"

Task 22: Flip the defaults to Native + release note

Goal: Make Native the default transport for both drivers (spec §8 P5), keeping Libplctag selectable, and write the client-visible release note.

Files:

  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs (default AbCipTransport.Native)
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs (fallback: AbCipTransport.Native)
  • Modify: src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs, src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs (same)
  • Modify: both AdminUI forms (relabel the options as "Native (default)" / "libplctag (fallback)")
  • Modify: tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs, tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs (the absent ⇒ Native expectation)
  • Modify: the CLIs' --transport default (native)
  • Modify: docs/drivers/AbCip.md, docs/drivers/AbLegacy.md (the default, and a "Release note" subsection)

Acceptance Criteria:

  • Blocked by Task 21 passing and Task 17 green for all AbLegacy profiles.
  • A config with no transport key runs Native in both drivers; prove it with the switch tests.
  • Existing unit tests that construct AbCipDriver with no injected factory and expect libplctag behaviour are updated to set Transport = Libplctag explicitly. Find them with grep -rn "new AbCipDriver(" tests | grep -v tagFactory.
  • The release note says:
    • The native transport is the default.
    • It reports BadTypeMismatch for mis-authored same-width types.
    • Set "transport": "Libplctag" to fall back.
    • AbCip writes and all of AbLegacy are simulator-proven only.
  • The full dotnet test ZB.MOM.WW.OtOpcUa.slnx passes, except suites that need fixtures that are not up; those are listed as skipped.

Verify: dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests → all pass.

Steps:

  • Step 1: Write the failing tests. Flip the switch tests' absent ⇒ X expectation to Native in AbCipTransportSwitchTests and AbLegacyTransportSwitchTests. Run them and confirm they fail.
  • Step 2: Flip the defaults. Change the four defaults (two options classes, two fallback: arguments), relabel the AdminUI options, and change the CLIs' default to native. Run the switch tests → pass.
  • Step 3: Pin the remaining libplctag tests. Find every test that builds a driver with no injected factory and relies on libplctag behaviour (grep -rn "new AbCipDriver(" tests | grep -v tagFactory, and the same for AbLegacyDriver), and set Transport = Libplctag on each.
  • Step 4: Write the release-note subsection in both driver docs.
  • Step 5: Run the full suite.
  • Step 6: Commit. git add src tests docs && git commit -m "feat(ab): native managed transport is the default for AbCip and AbLegacy; libplctag remains as fallback"

Self-review notes (author)

  • Spec coverage:
    • D1–D9 → T1–T16.
    • §5 status mapping → T1, T2, T3.
    • §5 press safety → T9 (throttle, storm test), T18 (single connection), T21 (one press, read-only).
    • §6 packaging → T1, T11.
    • §7.1 golden frames → T4–T8.
    • §7.2 scheduler and connection tests → T9, T10.
    • §7.3 both-transport suites → T17.
    • §7.4 differential harness → T18, T21.
    • §7.5 AdminUI /run → T19.
    • §8 P5 flip → T22.
    • P6 is out of plan, as recorded.
  • Deviations from the spec:
    • MaxInFlight is fixed at 1 (see Global Constraints).
    • LibplctagTagEnumerator is renamed SymbolListingEnumerator (T13), because its decode loop is transport-neutral.
    • AbCipTagCreateParams gains AuthoredType and UseConnectedMessaging (T12).
    • Explicit CipRouting modes (T9): the spec assumed only connected-or-UCMM, but libplctag's PCCC framing needs a third, Direct.
    • Fable review (2026-10-07) findings B1–B3 and 4–22 are folded in: the scheduler is per connection, caller cancellation is non-faulting, routing modes, the T5←T4 and T19←T17 dependencies, the narrowed guard regex, the 0x08 Large-FO fallback, keep-alive, the anchor lease, registration disposal, the BOOL 0xD3 exemption, PCCC chunking, FromCip parity, fixture locations, fragment continuation, the open-pair race, extra test files, the profile-driven routing flag, reinit semantics, the AbLegacy matrix filter, per-task models, and the bounded lease dispose.