183 KiB
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
BinaryPrimitivesand theCipWriter/CipReaderhelpers from Task 1. NoBitConverter, because it is endianness-dependent. - The decoders do not change.
CipSymbolObjectDecoderandCipTemplateObjectDecoderstay 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, namewith 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.
- The symbol listing entry is
AbStatusis the only status vocabulary above the adapters. After Task 2/3 no file outsideLibplctag*.csmay containusing libplctag. Task 11 enforces this with a source-scan guard.IAbCipTagRuntime.GetStatus()/IAbLegacyTagRuntime.GetStatus()return(int)AbStatusunder both transports. A zero value still means success, so every existingstatus != 0check stays correct.- The
Transportoption is serialized as a string ("Native"/"Libplctag"). That meansJsonStringEnumConverterin the AdminUI form, and the existing string DTO +ParseEnumin the factory extensions. An absent value meansLibplctaguntil 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.
EncapsulationClientserializes requests andRequestSchedulerhasMaxInFlight = 1hard-wired. Deviation from spec §3.1, which allowed a configurableMaxInFlight; per YAGNI there is no knob in v1. - One
RequestSchedulerper device connection, never per tag. It lives in theDeviceSessionPoolentry beside theCipConnectionand is exposed asConnectionLease.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 a1,0path.- The captures decide each family's mode (B3).
- Model tiers:
mechanical⇒ haiku,standard⇒ sonnet,frontier⇒ opus. Each task's metadata carriesmodel. - 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 editsZB.MOM.WW.OtOpcUa.slnx. No task editsDirectory.Packages.props. - Build gate for every task:
dotnet build ZB.MOM.WW.OtOpcUa.slnxwith 0 warnings (TreatWarningsAsErrorsis 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, matchingZB.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
Transportswitch 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.Transportproject, with one connection per device, transparent MSP coalescing, a driver-ownedAbStatus, 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.csprojandsrc/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj(add aProjectReferenceto Ab.Transport)
Acceptance Criteria:
dotnet build ZB.MOM.WW.OtOpcUa.slnxsucceeds with 0 warnings and both new projects appear in the slnx.AbStatus.Ok == 0, and every error member is negative.AbStatusCodes.FromCipmaps CIP general status as follows. It is aligned with the existingAbCipStatusMapper.MapCipGeneralStatus, so that existing mapping does not regress (review finding 13).0x00/0x0B→Ok0x04/0x05/0x16→NotFound0x06→Partial0x08→Unsupported0x0A→OutOfBounds0x0C/0x10→DeviceStateConflict0x0E/0x0F→NotAllowed0x13→TooSmall0x15→TooLarge0xFF+0x2105→OutOfBounds0xFF+0x2107→TypeMismatch- everything else, including
0x1E, →BadReply0x1E(embedded service error) is only meaningful on an MSP envelope, and the scheduler (T10) handles it before callingFromCip.
AbStatusCodes.FromPcccmaps STS0x00→Ok,0x10→Unsupported,0x20→NotFound,0x30→NotAllowed,0x40/0x50→DeviceStateConflict, and0xF0maps via EXT STS (0x06/0x07→NotFoundaddress problems, elseBadReply).CipReply.Parseround-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 isMicrosoft.Extensions.Logging.Abstractions. Check it is already pinned inDirectory.Packages.propswithgrep -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(replaceMapLibplctagStatus(Status)/TryGetStatus(LibPlcTagException)withMapStatus(AbStatus),MapStatus(int), andMapException) - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs(IsReachableStatus(AbStatus),IsReachableException(AbTransportException); construct runtimes via anIAbCipTagFactoryparameter that defaults tonew LibplctagTagFactory()) - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:1,1186-1191(catchAbTransportException) - Modify (tests):
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs,AbCipDriverProbeTests.cs,AbCipDriverReadTests.cs,AbCipStatusMapperTests.cs,AbCipDriverCodeReviewRegressionTests.cs(line 326 useslibplctag.Status.ErrorBadConnection),AbCipUnknownTagReadTests.cs,AbCipLoggingTests.cs(line 91, same),FakeAbCipTag.cs. Rungrep -rln "libplctag" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --include=*.csand migrate every hit exceptLibplctagStatusTranslationTests.csand the mapper parity oracle. - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs(its<see cref="AbCipStatusMapper.MapLibplctagStatus(int)"/>must becomeMapStatus(int). A dangling cref is CS1574, whichTreatWarningsAsErrorsturns 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/*.cslists onlyLibplctagTagRuntime.cs,LibplctagTagEnumerator.cs,LibplctagTemplateReader.csandLibplctagStatusTranslation.cs.LibplctagTagRuntime.GetStatus()returns(int)AbStatus, and every libplctag call that can throwLibPlcTagExceptionrethrowsAbTransportExceptionwith the translated status and the original asInnerException.- The translation table is the existing
MapLibplctagStatusbuckets re-expressed inAbStatus:Ok→Ok,Pending→Pending,ErrorTimeout→TimeoutErrorNotFound/ErrorNoMatch/ErrorBadDevice→NotFoundErrorNotAllowed→NotAllowedErrorOutOfBounds→OutOfBounds,ErrorTooLarge→TooLarge,ErrorTooSmall→TooSmallErrorUnsupported/ErrorNotImplemented→UnsupportedErrorPartial→Partial,ErrorAbort→AbortErrorBadReply→BadReply- every other member →
BadConnection
AbCipStatusMapper.MapStatus(AbStatus)produces exactly the OPC UA codes the old mapper produced for the corresponding libplctag members.TypeMismatchmaps toBadTypeMismatchandDeviceStateConflicttoBadDeviceFailure.PartialandBadReplymust still map toBadCommunicationError, asErrorPartial/ErrorBadReplydid.- The full existing
AbCip.Testssuite is green, with the only changes being test fakes constructingAbTransportException(AbStatus.X, ...)instead ofLibPlcTagException("ErrorX")and settingStatus = (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
FakeAbCipTagand every test that setsStatus =/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
AbCipDriverProbeTestsline 174/188, change the theory to iterateAbStatusvalues. "something the wrapper has never said" becomesnew AbTransportException(AbStatus.BadConnection, "..."), and the expectation is false.
- In
-
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; keepMapPcccStatus) - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs(IsReachableStatus(AbStatus), catchAbTransportException, optionalIAbLegacyTagFactoryparameter defaulting tonew LibplctagLegacyTagFactory()) - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs(lines ~318-325:MapLibplctagStatus→MapStatus; log text "transport status"; anyLibPlcTagExceptioncatch →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. Rungrep -rln "libplctag" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --include=*.csand migrate every hit except the translation test and the parity oracle. Also grepsrc/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacyforcref=".*MapLibplctagStatusand 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/*.cslists exactlyLibplctagLegacyTagRuntime.csandLibplctagLegacyStatusTranslation.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 oldMapLibplctagStatusgave for the corresponding members, proven by a parity table copied from the old switch before deleting it.- The full
AbLegacy.Testssuite 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
LibplctagStatusTranslationTestsverbatim intotests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyStatusTranslationTests.cs, and change the namespace toZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Testsand the class under test toLibplctagLegacyStatusTranslation. Add the mapper parity table toAbLegacyHostAndStatusTests, built from the oldAbLegacyStatusMapper.MapLibplctagStatusswitch atAbLegacyStatusMapper.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
LibplctagLegacyStatusTranslationwith the same code as Task 2'sLibplctagStatusTranslation(namespaceZB.MOM.WW.OtOpcUa.Driver.AbLegacy). Then make these changes:- Wrap
InitializeAsync/ReadAsync/WriteAsyncinLibplctagLegacyTagRuntimewithLibplctagLegacyStatusTranslation.Wrap(...). GetStatus() => (int)LibplctagLegacyStatusTranslation.ToAbStatus(_tag.GetStatus()).- The mapper becomes:
- Wrap
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 everyStatus.*use moves toAbStatus.*. - Step 4: Migrate the tests.
FakeAbLegacyTag.Statusvalues and anyLibPlcTagExceptionmove 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
.hexfile 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.hexlogix-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.hexlogix-write-dint.hex,logix-write-string.hexlogix-read-unknown-tag.hex(CIP 0x04, via patch 0002)logix-tags-listing.hex(@tags, via patch 0001)logix-forward-close.hexmicro800-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
CaptureFileTestsloads every.hexfile, parses every frame header, and asserts that each frame's encapsulation length field equals its data length.Captures/README.mdrecords, 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.mdhas 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 theirRoutingchoice 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; rundotnet run --project src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli -- --helpfor 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
.hexline format. Usescripts/ab-capture/capture.sh, which runstshark -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 withbrew install wiresharkif 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.csandCipTemplateObjectWireCaptureTests.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 exactly65 00 04 00 00000000 00000000 <8-byte context> 00000000 01 00 00 00.EncapsulationCodec.SendRRData(session, ctx, cip, timeoutSec)produces: header with command0x006F; data = interface handle00000000, timeoutu16, item count02 00, Null Address item00 00 00 00, then the Unconnected Data itemB2 00 <len u16>followed by the cip bytes.EncapsulationCodec.SendUnitData(session, connId, seq, cip)produces: command0x0070; data = interface handle00000000, timeout00 00, item count02 00, Connected Address itemA1 00 04 00 <connId u32>, Connected Data itemB1 00 <len+2 u16> <seq u16>followed by the cip bytes.EncapsulationCodec.ParseReplyreturns the header and the CPF item payload. For a connected reply it strips the sequence count. It throwsAbTransportException(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 onDisposeAsync. - 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:IsConnectedstays true, the gate is released, and the late reply is skipped by the next exchange.
- The frame write uses
- Faults.
- The timeout elapsing (no reply within
timeout) throwsAbTransportException(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. IsConnectedturns false after any fault.
- The timeout elapsing (no reply within
- Lifecycle. It connects, registers (and stores the session handle from the reply), and
- Parity: the bytes
RegisterSessionsends, apart from the sender context, equal the first>frame ofCaptures/logix-register-session.hex. FakeEipServeraccepts a script ofFunc<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: afterConnectAsync,SessionHandle == 0x11223344. -
SendRRData_returns_cip_payload: the handler repliesRRReply(req, [0xCC,0,0,0,0xC4,0,1,0,0,0]), and the result equals those 10 bytes. -
Concurrent_sends_are_serialized: fire 5SendRRDataAsynccalls 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: setStall = trueand use a 200 ms timeout. AssertAbTransportException(Timeout)andIsConnected == 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
OperationCanceledExceptionandIsConnected == 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 concurrentSendUnitDataAsynccalls, 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: afterDisposeAsync,Receivedcontains 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 flag0x10 | 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 form0x0F, 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", then91 03 "Arr" 00, then element28 03, then91 03 "Val" 00.- Element segments use
28 xxfor indices < 256,29 00 lo hifor indices < 65536, and2A 00 b0 b1 b2 b3above that. A multi-dimensional[1,2]emits two element segments. - A trailing purely numeric member (
Tag.5, the bit selector) is not encoded.EncodeSymbolicthrowsArgumentException, andCipPath.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.LogixTypeCodehas 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 assertingConvert.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
0x52toConnectionManagerbuilds: priority/tick0x0A, timeout ticks0x0E, embedded request sizeu16, the embedded request, a pad byte if that size is odd, the route path size in wordsu8, reserved0x00, 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(or0x5Bwhenp.Large), path size02,ConnectionManager, priority/tick0x0A, timeout ticks0x0E; - O→T id
00000000, T→O idp.TOConnectionId, connection serialp.ConnectionSerial, vendor idp.VendorId, originator serialp.OriginatorSerial; - timeout multiplier
p.TimeoutMultiplier, 3 reserved bytes; - O→T RPI
u32, then O→T params:u16 0x4200 | size(regular) oru32 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.
- service
ParseForwardOpenReplyreturns(OTConnectionId, TOConnectionId, OTApiMicros, TOApiMicros)on success. On a refusal it surfaces the general and extended status.ShouldFallBackToRegularOpen(CipReply)is true when the reply is general0x08(Service Not Supported — pre-v20 Logix and other targets answer a Large Forward Open this way, and libplctag falls back on it too) or general0x01with extended0x0109(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, countu16, then offsetsu16[count]measured from the start of the count field, then the requests concatenated.Parse(reply, count)returns oneCipReplyper 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). For0x02A0the next u16 is the handle.
- Symbol listing.
SymbolObjectServices.GetInstanceAttributeList(programName?, startInstance)→[91 len "Program:X" pad], then20 6B 25 00 <start u16>, then attribute count04 00and attributes02 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 layoutCipSymbolObjectDecoderexpects.ParseListingReplyreturns(byte[] Entries, uint LastInstance, bool More), where More ⇔ general status0x06. - ⚠️ If
Captures/logix-tags-listing.hexshows 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 isid u16, status u16, value.ReadTemplate(templateId, offset, bytes)→4C 03 20 6C 25 00 <id u16> <offset u32> <bytes u16>, with More ⇔ status0x06.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.hexequals ourReadTag(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. ParseReadReplyon the captured reply yieldsTypeCode == 0xC4and 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
@tagsreply entries decode throughCipSymbolObjectDecoder(reference the AbCip project from the test project only if that is needed; otherwise assert the entry byte layout directly).
- The ReadTag request in
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 onAbLegacyAddress; 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 againstslc500-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), andR→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.ResolveSubElementreturns(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)→ PCCC0F 00 <tns u16> A2 <byteCount u8> <file u8*> <type u8> <element u8*> <sub u8*>, whereu8*is a single byte when < 255, elseFF lo hi.PcccServices.SlcTypedWrite(tns, addr, data)→0F 00 <tns> AA <byteCount> <file*> <type> <element*> <sub*> <data>.- Byte-count limit (review finding 12).
byteCountis a single byte on the wire.SlcTypedRead/SlcTypedWrite(and the PLC-5 builders) throwAbTransportException(TooLarge)when the requested or supplied byte count exceedsPcccServices.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), eachu8*encoded. Plc5TypedWriteis0F 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/5path is not exercised by libplctag in the capture, mark the PLC-5 builders[Obsolete("Unverified — no capture")], make the native AbLegacy runtime throwAbTransportException(Unsupported)forplc5, and record that in the Task 20 docs. Do not ship unverified framing.
- The system address is a level-mask byte (bit0 = file level present? derive from
PcccServices.ParseReply(cipReply)strips the requestor-id echo (len u8+len-1bytes), readscmd u8 (0x4F), sts u8, tns u16, thenextSts u8ifsts == 0xF0, then the data. Non-zero STS →AbTransportException(AbStatusCodes.FromPccc(sts, ext)). A TNS that differs from the request →BadReply.- Capture parity:
SlcTypedReadforN7:0/F8:0equals the PCCC payload inside the captured Execute PCCC request, with the TNS normalized;ParseReplyon 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.hexby hand (write the decode as comments inPcccServicesTests) 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 theT4:0.ACCsub-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 theCipRoutingenum) - Create:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs(implementsICipChannel) - 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(includesConnectionLease) - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj(InternalsVisibleToforZB.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(aFakeEipServer.Handlerthat 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-insensitiveHost/PlcAttributeequality.ConnectionOptions:Routing(CipRouting { Connected, UnconnectedSend, Direct }) andAllowPacking(bool).TimeoutandConnectionSize(int).MinForwardOpenInterval(default 1 s).RpiMicros(default 2 000 000) andTimeoutMultiplier(default 1 ⇒ ×8, so connection expiry = 16 s).KeepAliveInterval(default = RPI × 8 / 2 = 8 s).VendorId(0x1337) andOriginatorSerial(random per process).
- Routing semantics (B3):
Connected:- On first use: TCP connect, RegisterSession, Forward Open. When
ConnectionSize > 511that 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 atMath.Min(ConnectionSize, 504). - Requests go via
SendUnitDataAsync. The client assigns the sequence (T5). MaxRequestBytes = negotiatedSize - 2.
- On first use: TCP connect, RegisterSession, Forward Open. When
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
SendAsyncreconnects through the throttle. The failing call itself throws its original exception. - Keep-alive (review finding 8, spec §9). In
Connectedmode, 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 forKeepAliveInterval.- 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, andSendAsyncuses that pair rather than re-reading fields after the lock. - Clean disposal. Disposal never disposes a
SemaphoreSlimthat waiters may still be queued on. It sets a_disposedflag checked under the lock, and later callers getAbTransportException(Abort). ForwardOpenThrottle(perDeviceKey; 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
MinForwardOpenIntervalof the previous attempt start waits for the remainder. If the caller's token fires first, it throwsAbTransportException(BadConnection, "Forward Open throttled"). - It uses an injected
TimeProvider.
- Storm test: against a
FakeCipTargetrefusing every Forward Open, 50 concurrentSendAsynccallers over 3 s of fake time produce ≤ 4 Forward Open frames. DeviceSessionPool:Acquire(DeviceKey, ConnectionOptions) → ConnectionLease.ConnectionLease : IAsyncDisposableexposesICipChannel ChannelandRequestScheduler Scheduler.- All leases for one key share one
CipConnectionand oneRequestScheduler(B1). The scheduler is built asnew RequestScheduler(connection, options.AllowPacking). A secondAcquirewith differentConnectionOptionsfor 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 (
Connectedonly) and then UnRegisterSession. - A later acquire on the same key builds new instances.
- Thread-safe: 100 parallel acquire/dispose tasks pass.
DeviceSessionPool.Sharedis 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) andinternal static int RequestsSent(one per channel send, keep-alives included), both viaInterlocked.
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 : TimeProviderinFakes/(about 20 lines: settableUtcNow, plus timers fired byAdvance(TimeSpan)). CheckDirectory.Packages.propsforMicrosoft.Extensions.TimeProvider.Testingfirst; useFakeTimeProviderif it is already pinned, and never add a package. - Write these tests:
Second_attempt_inside_interval_waitsAttempt_after_interval_proceeds_immediatelyOnly_one_attempt_in_flightRefusing_target_sees_at_most_one_forward_open_per_intervalLarge_open_service_not_supported_falls_back_to_regular_open_onceLarge_open_bad_size_falls_back_to_regular_open_onceConnection_in_use_does_not_fall_backDirect_routing_sends_bare_cip_even_with_a_pathUnconnectedSend_routing_wraps_in_0x52Connected_routing_never_sends_bareCaller_cancellation_does_not_reconnect(cancel mid-request, then send again ⇒ForwardOpenCountunchanged)Timeout_reconnects_through_the_throttleIdle_connection_sends_one_keepalive_per_intervalTraffic_suppresses_keepaliveLeases_for_one_key_share_one_schedulerLast_lease_dispose_sends_forward_close_then_unregisterConcurrent_acquire_and_release_is_safeSend_after_dispose_throws_Abort
- Use
- 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 alock, whereEntry = (CipConnection Conn, RequestScheduler Scheduler, ConnectionOptions Options, int RefCount). - Keep a separate, never-pruned
Dictionary<DeviceKey, ForwardOpenThrottle>. Acquirecreates the entry on first use (new CipConnection(key, o, throttle, time),new RequestScheduler(conn, o.AllowPacking)) and incrementsRefCount.ConnectionLease.DisposeAsyncis idempotent (Interlockedflag). It decrements the count and, at zero, removes the entry; outside the lock it awaitsScheduler.DisposeAsync(), thenConn.DisposeAsync().- The pool constructor takes an optional
TimeProvider(defaultTimeProvider.System).
- Keep a
- Step 6: Run.
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests→ all pass, then runRefusing_target_sees_at_most_one_forward_open_per_interval20× 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)exposesTask<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 exactlyMpack into one frame, and two whose MSP size isM + 1go as two frames. - A single request larger than
MaxRequestBytesis sent bare. Callers must fragment;LogixTagClientdoes. - 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 than0x1Eembedded-service-error), or the channel throws, every waiter in that batch faults with the sameAbTransportException. - 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
_stoptoken, 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
CancellationTokenRegistrationis stored on itsPendingrecord 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 countingCancellationTokenSourcesubclass, or assertPendingregistrations 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 overFakeCipChannel. 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 atoffset = bytesReceivedSoFar, appending each reply's data until a reply has status0x00. 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
0x06per service. That is correct behaviour (the fragment loop recovers it), not an error.
- If the reply status is
WriteAsync(path, type, handle, elements, data)sends one Write Tag when it fits, otherwise Write Tag Fragmented chunks ofMaxRequestBytes - 12 - path.Length, aligned down to the element width.- Non-zero final status ⇒
AbTransportException(reply.Status).
FakeCipChannellets a test setMaxRequestBytes, 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
FakeCipChanneland 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: holdGatewith the first request in flight, enqueue 3 more, release. AssertSent.Count == 2andSent[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 aCipReplywithGeneralStatus == 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
LogixTagClientto the acceptance criteria. It holds aRequestSchedulerand anICipChannel(forMaxRequestBytes), and builds requests withLogixServices. - 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.BaseDirectoryto the directory containingZB.MOM.WW.OtOpcUa.slnx, then scans every*.csundersrc/(excludingbin/obj) for code references:using libplctagorlibplctag.(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*.csandsrc/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/Libplctag*.csonly. 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;tosrc/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(mapsAbCipTagCreateParams→DeviceKey+ConnectionOptions; shared with Task 13) - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs(add trailing optionalsAbCipDataType? AuthoredType = null, bool UseConnectedMessaging = truetoAbCipTagCreateParams) - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs(passUseConnectedMessaging: profile.SupportsConnectedMessaging) - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs(EnsureTagRuntimeAsyncpassesAuthoredType: def.DataType;DeviceState's create-params builder passesUseConnectedMessaging: 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 aProjectReferenceto 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 staysinternal. Also addInternalsVisibleToforZB.MOM.WW.OtOpcUa.Driver.AbCip.Teststo 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: ifp.UseConnectedMessagingis false, useUnconnectedSendwhenp.CipPathis non-empty andDirectwhen it is empty. Otherwise useConnected.UseConnectedMessagingis a new trailing optionalbool UseConnectedMessaging = trueonAbCipTagCreateParams. It is set at every construction site fromAbCipPlcFamilyProfile.SupportsConnectedMessaging, so the family profile stays the single authority and string-matching"micro800"is not needed (review finding 18). The sites areAbCipDriverDeviceState(aroundAbCipDriver.cs:1690-1700, whereAllowPacking/ConnectionSizeare already merged) andAbCipDriverProbe; find any others withgrep -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:
InitializeAsyncacquires the lease fromDeviceSessionPool.Sharedand builds a per-runtimeLogixTagClientover the lease's sharedSchedulerandChannel. It must never construct its ownRequestScheduler: that would defeat MSP coalescing across tags (B1). It encodes the path once, splitting off a trailing bit selector withCipPath.SplitBitSelector; that is stored as the runtime's implicit bit.- It performs one read to prove the tag exists. That matches libplctag, whose
InitializeAsyncerrors for an unknown tag; the driver's 05/STAB-8 logic depends on create throwingNotFound. ReadAsyncreadsp.ElementCountelements, stores the data,TypeCodeandStructHandle, and sets statusOk. OnAbTransportExceptionit stores the exception's status and rethrows, matching libplctag's exception-on-failure behaviour.GetSize()returns the data length.
- Decode is byte-identical to
LibplctagTagRuntimefor everyAbCipDataType(Bool/SInt/USInt/Int/UInt/DInt/UDInt/LInt/ULInt/Real/LReal/String/Dt). Specifically:- Bool with a bit index ⇒ bit
nof the little-endian buffer. - Bool without ⇒
byte[0] != 0. - String ⇒ Logix STRING
u32 LEN+DATA, ASCII,LENclamped to the data length − 4. DecodeArrayuses stride =data.Length / countwhen it divides evenly, else the static width (the sameResolveArrayStriderule; callLibplctagTagRuntime.ResolveArrayStridewithelementSize: nullto share it).- Bool arrays are bit-packed (bit
iof the buffer).
- Bool with a bit index ⇒ bit
- Type check: after a read, if
TypeCodeis atomic and differs from the authored type's Logix code,GetStatus()returns(int)AbStatus.TypeMismatchandDecode*returns null. The mapping isAbCipDataType→LogixTypeCode, withDt → DInt;String/Structureare exempt. Exemptions:- (a) A BOOL read with a bit index against a DINT host is not a mismatch.
- (b) Authored
Boolwith returned0xD3(the Logix packed BOOL-array type) is not a mismatch (review finding 11). - Tests cover both exemptions, plus a positive mismatch.
- Encode/write:
EncodeValuefills a managed write buffer with the same encodings asLibplctagTagRuntime.EncodeValue, including the sameNotSupportedExceptioncases.WriteAsyncwrites with type = the last readTypeCodeif known, else the authored type's code.- For String, it uses
Struct+ the lastStructHandle. If none is known it performs a read first; that read happens insideWriteAsync, 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.
Disposereleases 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 : IAbCipTagFactorycreatesNativeAbCipTagRuntime.- Test seam:
internal static NativeAbCipTagRuntime ForTest(ICipChannel channel, AbCipTagCreateParams p)builds a runtime over the given channel with a privateRequestScheduler(channel, p.AllowPacking)(test-only; no pool lease). Production code always goes through the factory andDeviceSessionPool.Shared. - Unit tests (against
FakeCipChannelinjected through aninternalconstructor that takes anICipChannel):- decode parity for each type, using a buffer captured from
LibplctagTagRuntimesemantics (hard-coded bytes); - the TypeMismatch case (REAL returned for a DInt-authored tag);
- an unknown tag ⇒
InitializeAsyncthrowsAbTransportException(NotFound); - a STRING write sends
Struct+ the handle learned from the prior read; - a 1-element array is read as an array (
IsArray).
- decode parity for each type, using a buffer captured from
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
BinaryPrimitivesfor every decode, neverBitConverter. - 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 ofLibplctagTagEnumerator.cs) - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs(keeps onlyLibplctagListingReader+LibplctagTagEnumeratorFactory) - Modify:
tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/files referencingLibplctagTagEnumerator(rename toSymbolListingEnumerator) - 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:
NativeAbCipTagEnumeratorreuses theLibplctagTagEnumerator(Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> readListing)seam. Its listing reader:"@tags"⇒ pagesSymbolObjectServices.GetInstanceAttributeList(null, start)from instance 0, appending entry bytes. While the reply isMore, it continues fromLastInstance + 1."Program:X.@tags"⇒ the same withprogramName: "X".- It returns the concatenated bytes.
- So
NativeAbCipTagEnumeratorisnew LibplctagTagEnumerator(NativeListingReader.ReadAsync), wrapped in a class for the factory. RenameLibplctagTagEnumerator→SymbolListingEnumerator(it has no libplctag dependency apart from its default constructor). Move the libplctag default reader intoLibplctagTagEnumerator.csasLibplctagListingReader, so the guard (Task 11) still allows it.
NativeAbCipTemplateReader.ReadAsync(params, id):GetAttributes(id)givesdefWords,structBytes,members,handle.- It then sends
ReadTemplate(id, offset, chunk)repeatedly, withchunk = 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. Status0x06continues. - 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 thatCipTemplateObjectDecoder.Decode(nativeBuffer)equalsDecode(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
CipSymbolObjectRealControllerCaptureTestsentries: split them across two pages with a0x06status between, and assert the decoded tag list is identical.
- Both factories (
NativeAbCipTagEnumeratorFactory,NativeAbCipTemplateReaderFactory) exist and lease fromDeviceSessionPool.SharedviaNativeAbCipDevice. They send through the lease's sharedScheduler, 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.csand 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.csasSymbolListingEnumerator, leavingLibplctagListingReader.ReadAsyncandLibplctagTagEnumeratorFactory(which now returnsnew SymbolListingEnumerator(LibplctagListingReader.ReadAsync)) inLibplctagTagEnumerator.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 + linkFakeCipChannel.cs, as in Task 12)
Acceptance Criteria:
- Connection:
DeviceKeyis built fromAbLegacyTagCreateParamsthe same way as Task 12 (default port 44818).Routingcomes 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 forcesEffectiveCipPath = "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.
- Expected:
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 fromResolveSubElement. ABitIndexis 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:
ReadAsyncdispatches byplcattribute:slc500/micrologix/logixpccc⇒SlcTypedRead;plc5⇒Plc5TypedRead, orAbTransportException(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.
InitializeAsyncperforms one read, for the sameNotFound-on-create reason as Task 12.
- Decode is identical to
LibplctagLegacyTagRuntime:- Bit with index ⇒ bit of the LE word (
/Nup 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 againstslc500ST 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). DecodeArrayuses the offsets documented inLibplctagLegacyTagRuntime.DecodeArray.
- Bit with index ⇒ bit of the LE word (
- Encode follows
LibplctagLegacyTagRuntime.EncodeValuecase by case, with the sameNotSupportedExceptionfor 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 ⇒
NotFoundon 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(addpublic AbCipTransport Transport { get; init; } = AbCipTransport.Libplctag;) - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs(DTOstring? 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(useAbCipTransportFactories.For(opts.Transport).Tags) - Modify:
src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/(--transport native|libplctag, defaultlibplctag; 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--transportparse test)
Acceptance Criteria:
public enum AbCipTransport { Libplctag, Native }.AbCipTransportFactories.For(t)returns(IAbCipTagFactory Tags, IAbCipTagEnumeratorFactory Enumerators, IAbCipTemplateReaderFactory Templates): the native trio forNative, the libplctag trio forLibplctag.- Explicitly injected factories (tests) still win over the option.
- JSON configs:
"transport": "Native"⇒ Native;"transport": "native"⇒ Native (case-insensitive, as with the existingParseEnum); absent ⇒ Libplctag;"transport": "Bogus"⇒ the same failure the existingParseEnumraises for a badPlcFamily(do not invent a new behaviour). - Reinit does NOT swap transports, by design (review finding 19).
AbCipDriver.ReinitializeAsync(AbCipDriver.cs:401) isShutdownAsync+InitializeAsyncover 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 changingtransporttakes 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 callReinitializeAsync, raise it as a follow-up in the commit message rather than widening this task.
- Document in the option's XML doc and in
- The probe deserializes with its own
JsonStringEnumConverter, so a bad"transport"reports "Config JSON is invalid", not theParseEnummessage. That is acceptable; assert that exact behaviour in the probe test rather than the driver's. - The probe with
Transport: Nativeconstructs aNativeAbCipTagRuntime. Prove it with a test that uses an unreachable127.0.0.1:1endpoint and asserts the failure message comes from the native path ("TCP connect … failed"), not libplctag's. - The CLI
--transport nativereachesAbCipDriverOptions.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(Transportproperty, defaultLibplctag) - 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 nativereaches the options. - A reinit with a changed transport keeps the constructed transport (
AbLegacyDriverreinit 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 for127.0.0.1:1, and the CLI--transport nativeparse. - 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|libplctagto the CLI, defaulting tolibplctag, and change its "via libplctag" help text to "via libplctag or the native transport".
- Add the enum, the options property and the DTO field. Parse it with the file's existing enum-parse helper, falling back to
- 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.csand every other test in that project that constructsAbCipDriverOptions(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.csand 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.Profilesyields(profile, transport)for every profile × both transports. The theories switch fromMemberData(nameof(Profiles))toMemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix)), and options getTransport = transport.- The AbLegacy matrix is its own class in the AbLegacy integration project, using
AbLegacyTransport, and it preserves the existingAB_LEGACY_COMPOSE_PROFILEfilter thatAbLegacyReadSmokeTests.Profilesapplies today (AbLegacyReadSmokeTests.cs:26-35): it must filterKnownProfiles.Allby 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 underLibplctag.
- AbCip:
- 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
Unsupportedpath from Task 8, markedSkip = "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 theab_serverimage 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
TransportMatrixand 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.csfor the name). - Step 3: Triage every Native-only failure. Reproduce it with the CLI (
--transport nativevs--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.csandNativeAbCipTagRuntime.cs(addinternal ReadOnlyMemory<byte> RawBuffer) - Modify:
src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj(InternalsVisibleToforZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests)
Acceptance Criteria:
- Env gating:
AB_DIFF_ENDPOINT(host:port),AB_DIFF_PATH(default1,0),AB_DIFF_FAMILY(defaultCompactLogix).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
WriteAsyncorEncodeValue. A guard test reflects overTransportDifferentialTestsand fails if any method body referencesWriteAsync(scan its source file). - Browse parity:
@tagsplus every program listing through both enumerators decode to identicalAbCipDiscoveredTagsets (order-insensitive). EveryTemplateInstanceIdseen decodes to an identicalAbCipUdtShapethrough both template readers. - Read parity: for each selected tag, under both transports:
- Run
InitializeAsync+ReadAsync. - Assert
GetStatus()is equal, except that nativeTypeMismatchwhere libplctag saysOkis 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> RawBufferto both runtimes: libplctag's via_tag.GetBuffer(), native's via its stored data. AddInternalsVisibleTofor the integration test assembly in the AbCip csproj. - Assert the decoded values are equal.
- Run
- 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, usingCipConnection.ForwardOpenCountfrom Task 9 (reset it at the start of the run).
- Before any native work, the harness acquires one
- 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(controllogixprofile) withAB_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 matchingAbServerTheoryAttribute. - 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, anInputSelectin 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)" ⇒
Libplctagand "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_jsonOptswithJsonStringEnumConverter(AbCipDriverForm.razor:108-113); verify the AbLegacy form has the same converter and add it if missing. - Loading a config without
transportshows "libplctag (default)" and re-emits without atransportkey, or with"Libplctag"; either is fine, but pick one and test it. - Live
/runon docker-dev (login is disabled; drive it yourself with the Chrome tools againsthttp://localhost:9200):- Bring up the AbCip
controllogixfixture on10.100.0.35. - In
/raw, create or edit an AbCip driver with a deviceab://10.100.0.35:44818/1,0, set Transport = Native, and add the raw tagTestDINT. - Deploy.
- 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 getGood. - 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. - Flip back to libplctag, redeploy, and read
Goodagain. - Record the output under the gate-results doc's
## AdminUI /run (T19)heading only.
- Bring up the AbCip
- Inverted-gate check (memory: "an unchanged observable proves nothing"): with Transport = Native, point the device at a port where no
ab_serveris 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.csfiles above, assertToOptions(FromOptions(x)).Transport == x.Transportfor 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
Transportfield to eachFormModeland toToOptions/FromOptions, plus theInputSelectin the advanced section, and make sure the AbLegacy form's JSON options includeJsonStringEnumConverter. Run the round-trip test → pass. - Step 3: Rebuild the docker-dev images with
docker-dev/deploy.sh, asdocker-dev/README.mddocuments; do not guess flags. - Step 4: Run the live
/runsequence 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 (Transportoption)", "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.mdanddocs/drivers/AbLegacy-Test-Fixture.md(suites now run per transport; the capture recipescripts/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'smainper the CLAUDE.md procedure
Acceptance Criteria:
docs/drivers/AbCip.mdstates explicitly:- The native path reports
BadTypeMismatchfor 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.
- The native path reports
- Every config key added (
transport) is documented with its values, its default and string serialization. - The scadaproj history entry is committed on
mainand pushed. The OtOpcUa index row in../scadaproj/CLAUDE.mdis 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
/runevidence 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
acceptanceCriteriahas 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, path1,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. EveryTypeMismatchexpected-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.RequestsSentfrom 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 WriteAsyncover both differential test files returns 0.
- Recorded results: under the
## Real press (T21)and## Latency (T21)headings, the results doc carries the rawdotnet testoutput, 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(defaultAbCipTransport.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'
--transportdefault (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
transportkey runs Native in both drivers; prove it with the switch tests. - Existing unit tests that construct
AbCipDriverwith no injected factory and expect libplctag behaviour are updated to setTransport = Libplctagexplicitly. Find them withgrep -rn "new AbCipDriver(" tests | grep -v tagFactory. - The release note says:
- The native transport is the default.
- It reports
BadTypeMismatchfor 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.slnxpasses, 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
NativeinAbCipTransportSwitchTestsandAbLegacyTransportSwitchTests. 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 tonative. 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 forAbLegacyDriver), and setTransport = Libplctagon 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:
MaxInFlightis fixed at 1 (see Global Constraints).LibplctagTagEnumeratoris renamedSymbolListingEnumerator(T13), because its decode loop is transport-neutral.AbCipTagCreateParamsgainsAuthoredTypeandUseConnectedMessaging(T12).- Explicit
CipRoutingmodes (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,
FromCipparity, 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.