From d7145ddfa5c752bb571d65bcc924e8a45eefbb67 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 7 Oct 2026 16:23:09 -0400 Subject: [PATCH] docs(plans): native AB transport tasks.json status sync --- docs/plans/2026-10-07-native-ab-transport.md.tasks.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-10-07-native-ab-transport.md.tasks.json b/docs/plans/2026-10-07-native-ab-transport.md.tasks.json index 5c639925..ce324f2a 100644 --- a/docs/plans/2026-10-07-native-ab-transport.md.tasks.json +++ b/docs/plans/2026-10-07-native-ab-transport.md.tasks.json @@ -38,7 +38,7 @@ { "id": 5, "subject": "Task 5: EtherNet/IP encapsulation codec + `EncapsulationClie", - "status": "pending", + "status": "completed", "description": "**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.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationHeader.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationCodec.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationClient.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationCodecTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationClientTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeEipServer.cs`\n\n**Acceptance Criteria:**\n- [ ] `EncapsulationCodec.RegisterSession()` produces exactly `65 00 04 00 00000000 00000000 <8-byte context> 00000000 01 00 00 00`.\n- [ ] `EncapsulationCodec.SendRRData(session, ctx, cip, timeoutSec)` produces: header with command `0x006F`; data = interface handle `00000000`, timeout `u16`, item count `02 00`, Null Address item `00 00 00 00`, then the Unconnected Data item `B2 00 ` followed by the cip bytes.\n- [ ] `EncapsulationCodec.SendUnitData(session, connId, seq, cip)` produces: command `0x0070`; data = interface handle `00000000`, timeout `00 00`, item count `02 00`, Connected Address item `A1 00 04 00 `, Connected Data item `B1 00 ` followed by the cip bytes.\n- [ ] `EncapsulationCodec.ParseReply` returns the header and the CPF item payload. For a connected reply it strips the sequence count. It throws `AbTransportException(BadReply)` when encapsulation status \u2260 0, the length is wrong, or the expected item is missing.\n- [ ] `EncapsulationClient`:\n - **Lifecycle.** It connects, registers (and stores the session handle from the reply), and `UnRegisterSession`s on `DisposeAsync`.\n - **Single flight.** It serializes requests with a `SemaphoreSlim(1,1)`.\n - **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 \u2192 1.\n - **Matching replies.**\n - Unconnected replies match on the 8-byte sender context.\n - Connected (0x70) replies match on the echoed sequence count.\n - 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.\n - **Caller cancellation (B2).**\n - The frame write uses `CancellationToken.None`, so a write is never half-sent.\n - If the *caller's* token is cancelled during the reply wait, the call throws `OperationCanceledException`. The connection is **not** faulted: `IsConnected` stays true, the gate is released, and the late reply is skipped by the next exchange.\n - **Faults.**\n - The *timeout* elapsing (no reply within `timeout`) throws `AbTransportException(Timeout)` and **faults** the connection by closing the socket. A target that has stopped answering cannot be trusted.\n - A socket or I/O error throws `AbTransportException(BadConnection)` and faults the connection.\n - `IsConnected` turns false after any fault.\n- [ ] Parity: the bytes `RegisterSession` sends, apart from the sender context, equal the first `>` frame of `Captures/logix-register-session.hex`.\n- [ ] `FakeEipServer` accepts a script of `Func` steps, records every request it receives, and can be told to stall (to test timeouts) or drop the connection.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \"FullyQualifiedName~Eip\"` \u2192 all pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 5.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationClient.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationCodec.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationHeader.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationClientTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationCodecTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeEipServer.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \\\"FullyQualifiedName~Eip\\\"\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 7}\n```", "blockedBy": [ 1, @@ -57,7 +57,7 @@ { "id": 7, "subject": "Task 7: CIP service codecs (Connection Manager, MSP, Logix R", - "status": "pending", + "status": "completed", "description": "**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.\n\n**Files:**\n- 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)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/MultipleServicePacket.cs` (0x0A)\n- 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)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/SymbolObjectServices.cs` (Get Instance Attribute List 0x55 on class 0x6B)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/TemplateObjectServices.cs` (Get Attribute List 0x03 + Read 0x4C on class 0x6C)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ConnectionManagerServicesTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/MultipleServicePacketTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixServicesTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/SymbolObjectServicesTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/TemplateObjectServicesTests.cs`\n\n**Acceptance Criteria:**\n- [ ] **Unconnected Send** `0x52` to `ConnectionManager` builds: priority/tick `0x0A`, timeout ticks `0x0E`, embedded request size `u16`, the embedded request, a pad byte if that size is odd, the route path size in words `u8`, reserved `0x00`, then the route. When the route is empty (Micro800), the builder is not used and the embedded request is sent directly.\n- [ ] **Forward Open.** `ForwardOpen(ForwardOpenParams p)` emits:\n - service `0x54` (or `0x5B` when `p.Large`), path size `02`, `ConnectionManager`, priority/tick `0x0A`, timeout ticks `0x0E`;\n - O\u2192T id `00000000`, T\u2192O id `p.TOConnectionId`, connection serial `p.ConnectionSerial`, vendor id `p.VendorId`, originator serial `p.OriginatorSerial`;\n - timeout multiplier `p.TimeoutMultiplier`, 3 reserved bytes;\n - O\u2192T RPI `u32`, then O\u2192T params: `u16 0x4200 | size` (regular) or `u32 0x42000000 | size` (large);\n - T\u2192O RPI, then T\u2192O params (same encoding);\n - transport/trigger `0xA3`, connection path size in words, connection path = `EncodeRoute(cipPath)` + `MessageRouter`.\n- [ ] `ParseForwardOpenReply` returns `(OTConnectionId, TOConnectionId, OTApiMicros, TOApiMicros)` on success. On a refusal it surfaces the general and extended status.\n- [ ] `ShouldFallBackToRegularOpen(CipReply)` is true when the reply is general `0x08` (Service Not Supported \u2014 pre-v20 Logix and other targets answer a Large Forward Open this way, and libplctag falls back on it too) **or** general `0x01` with extended `0x0109` (invalid connection size). Task 9 uses it.\n- [ ] `ForwardClose(serial, vendor, origSerial, route)` \u2192 `4E 02 20 06 24 01 0A 0E 00 `.\n- [ ] **MSP.** `MultipleServicePacket.Build(IReadOnlyList requests)` \u2192 `0A 02 20 02 24 01`, count `u16`, then offsets `u16[count]` measured from the start of the count field, then the requests concatenated. `Parse(reply, count)` returns one `CipReply` per embedded reply. `BuildSize(sizes)` returns the exact byte size of an MSP for the given request sizes, without allocating (Task 10 uses it for packing).\n- [ ] **Read/Write Tag:**\n - `ReadTag(path, elements)` \u2192 `4C `.\n - `ReadTagFragmented(path, elements, offset)` \u2192 `52 \u2026 `.\n - `WriteTag(path, typeCode, structHandle?, elements, data)` \u2192 `4D \u2026 [ when type == 0x02A0] `.\n - `WriteTagFragmented` \u2192 `53 \u2026 [] `.\n - `ParseReadReply(CipReply)` \u2192 `(ushort TypeCode, ushort? StructHandle, ReadOnlyMemory Data)`. For `0x02A0` the next u16 is the handle.\n- [ ] **Symbol listing.** `SymbolObjectServices.GetInstanceAttributeList(programName?, startInstance)` \u2192\n - `[91 len \"Program:X\" pad]`, then `20 6B 25 00 `, then attribute count `04 00` and attributes `02 00 07 00 08 00 01 00` (type, element length, array dims, name).\n - Each reply entry, as the controller sends it, is `u32 instance, u16 type, u16 elemLen, u32\u00d73 dims, u16 nameLen, name` \u2014 exactly the entry layout `CipSymbolObjectDecoder` expects. `ParseListingReply` returns `(byte[] Entries, uint LastInstance, bool More)`, where More \u21d4 general status `0x06`.\n - \u26a0\ufe0f If `Captures/logix-tags-listing.hex` shows libplctag requesting a different attribute set or order, **match the capture** and update this criterion. The decoder layout is the invariant; the attribute list is the means.\n- [ ] **Templates:**\n - `TemplateObjectServices.GetAttributes(templateId)` \u2192 `03 03 20 6C 25 00 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).\n - `ParseAttributes` \u2192 `(uint DefWords, uint StructBytes, ushort Members, ushort Handle)`; each attribute's reply is `id u16, status u16, value`.\n - `ReadTemplate(templateId, offset, bytes)` \u2192 `4C 03 20 6C 25 00 `, with More \u21d4 status `0x06`.\n - `TemplateObjectServices.BuildDecoderBuffer(id, attrs, rawTemplate)` \u2192 the 14-byte header + raw bytes from Global Constraints.\n- [ ] **Capture parity tests** (each extracts the CIP payload via `CaptureFile.CipPayload`):\n - The ReadTag request in `logix-read-dint.hex` equals our `ReadTag(EncodeSymbolic(\"TestDINT\"), 1)`. If libplctag wrapped it in an MSP or Unconnected Send, unwrap the same way in the test, and document that wrapping in a test comment.\n - `ParseReadReply` on the captured reply yields `TypeCode == 0xC4` and 4 data bytes.\n - 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.\n - The captured `@tags` reply entries decode through `CipSymbolObjectDecoder` (reference the AbCip project from the test project only if that is needed; otherwise assert the entry byte layout directly).\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \"FullyQualifiedName~Services|FullyQualifiedName~MultipleServicePacket\"` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 7.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/MultipleServicePacket.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixServices.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/SymbolObjectServices.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/TemplateObjectServices.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ConnectionManagerServicesTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/MultipleServicePacketTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixServicesTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/SymbolObjectServicesTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/TemplateObjectServicesTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \\\"FullyQualifiedName~Services|FullyQualifiedName~MultipleServicePacket\\\"\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 10}\n```", "blockedBy": [ 4, @@ -67,7 +67,7 @@ { "id": 8, "subject": "Task 8: PCCC codec (Execute PCCC, SLC/MicroLogix typed logic", - "status": "pending", + "status": "completed", "description": "**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.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccFileType.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccAddress.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccServices.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccAddressTests.cs`\n\n**Acceptance Criteria:**\n- [ ] `PcccAddress(string FileLetter, int? FileNumber, int Element, int? SubElement)` is the transport-side address. It does not depend on `AbLegacyAddress`; Task 14 maps between the two.\n- [ ] 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\u21920, I\u21921, S\u21922`. **Verify every code against `slc500-read-n7.hex` / `slc500-read-f8.hex`**; the capture wins if it disagrees.\n- [ ] Sub-element indices: `T/C` \u2192 `PRE=1, ACC=2` (word offsets within the 3-word element, where word 0 is the control bits), and `R` \u2192 `LEN=1, POS=2`. The bit sub-elements (`EN/TT/DN/CU/CD/OV/UN`) read word 0 and are returned as `(sub=0, bit)`. `PcccAddress.ResolveSubElement` returns `(int word, int? bit)`.\n- [ ] `PcccServices.ExecutePccc(requestorVendor, requestorSerial, pccc)` \u2192 `4B 02 20 67 24 01 07 `.\n- [ ] `PcccServices.SlcTypedRead(tns, addr, byteCount)` \u2192 PCCC `0F 00 A2 `, where `u8*` is a single byte when < 255, else `FF lo hi`.\n- [ ] `PcccServices.SlcTypedWrite(tns, addr, data)` \u2192 `0F 00 AA `.\n- [ ] **Byte-count limit (review finding 12).** `byteCount` is a single byte on the wire. `SlcTypedRead`/`SlcTypedWrite` (and the PLC-5 builders) throw `AbTransportException(TooLarge)` when the requested or supplied byte count exceeds `PcccServices.MaxDataBytes = 236`; a silent `(byte)` wrap would be a corrupt write. Test 236 (ok) and 237 (throws) for both. Task 14 chunks larger arrays.\n- [ ] `PcccServices.Plc5TypedRead(tns, addr, elements)` \u2192 `0F 00 68 `.\n - The system address is a level-mask byte (bit0 = file level present? **derive from `plc5-read-n7.hex`**) followed by level values (`$N7:0` \u21d2 data table section, file 7, element 0), each `u8*` encoded.\n - `Plc5TypedWrite` is `0F 00 67 `.\n - \u26a0\ufe0f The PLC-5 framing must be **copied from the capture**. If ab_server's `--plc=PLC/5` path is not exercised by libplctag in the capture, do **not** ship PLC-5 builders at all. Instead expose `PcccServices.Plc5Verified = false`, have the native AbLegacy runtime throw `AbTransportException(Unsupported)` for `plc5`, and record that in the Task 20 docs. Do not use `[Obsolete]`: under `TreatWarningsAsErrors` it is a build error at every call site.\n- [ ] `PcccServices.ParseReply(cipReply)` strips the requestor-id echo (`len u8` + `len-1` bytes), reads `cmd u8 (0x4F), sts u8, tns u16`, then `extSts u8` if `sts == 0xF0`, then the data. Non-zero STS \u2192 `AbTransportException(AbStatusCodes.FromPccc(sts, ext))`. A TNS that differs from the request \u2192 `BadReply`.\n- [ ] Capture parity: `SlcTypedRead` for `N7:0` / `F8:0` equals the PCCC payload inside the captured Execute PCCC request, with the TNS normalized; `ParseReply` on the captured reply yields the 2 / 4 data bytes.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \"FullyQualifiedName~Pccc\"` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 8.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccAddress.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccFileType.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccServices.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccAddressTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \\\"FullyQualifiedName~Pccc\\\"\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 10}\n```", "blockedBy": [ 4 @@ -87,7 +87,7 @@ { "id": 10, "subject": "Task 10: RequestScheduler \u2014 MSP coalescing + fragmentation", - "status": "pending", + "status": "completed", "description": "**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.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/RequestScheduler.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTagClient.cs` (read/write helpers over the scheduler, including the fragment loops)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/RequestSchedulerTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTagClientTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipChannel.cs`\n\n**Acceptance Criteria:**\n- [ ] `RequestScheduler(ICipChannel channel, bool allowPacking)` exposes `Task SendAsync(byte[] request, CancellationToken ct)`.\n- [ ] **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 \u21d2 one MSP. Exactly one \u21d2 sent bare. `allowPacking == false` \u21d2 always bare.\n- [ ] **Boundary tests:** with `MaxRequestBytes = M`, two requests whose MSP size is exactly `M` pack into one frame, and two whose MSP size is `M + 1` go as two frames.\n- [ ] **A single request larger than `MaxRequestBytes`** is sent bare. Callers must fragment; `LogixTagClient` does.\n- [ ] **Reply fan-out:** each waiter gets its own embedded `CipReply`, with its own general status, so one failing service in an MSP fails only its own waiter. If the MSP envelope itself fails (non-zero general status other than `0x1E` embedded-service-error), or the channel throws, every waiter in that batch faults with the same `AbTransportException`.\n- [ ] **Cancellation:**\n - A waiter cancelled while queued is removed and never sent.\n - A waiter cancelled while its batch is in flight gets `OperationCanceledException`, and the batch's other waiters still complete.\n - **The channel call always uses the scheduler's own `_stop` token, never a waiter's**, for single sends as well as MSPs (B2). So one caller's cancellation can never abort, or fault, a shared exchange.\n - Each waiter's `CancellationTokenRegistration` is stored on its `Pending` record and **disposed when the TCS completes** (review finding 10). A test makes 10 000 sends on one long-lived token and asserts no growth: measure with a counting `CancellationTokenSource` subclass, or assert `Pending` registrations are disposed via a test hook.\n- [ ] **Owner:** one scheduler per device connection, created by `DeviceSessionPool` (T9). This task builds only the class and tests it over `FakeCipChannel`.\n- [ ] **`LogixTagClient`:**\n - `ReadAsync(path, elements)` sends Read Tag.\n - If the reply status is `0x06` (Partial), it **keeps** that reply's data and continues with Read Tag Fragmented at `offset = bytesReceivedSoFar`, appending each reply's data until a reply has status `0x00`. That avoids double-counting the first chunk (review finding 15).\n - 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.\n - **Reply-size note:** packing bounds only the *request* size, so many large reads packed together can produce a reply that the controller truncates with `0x06` per service. That is correct behaviour (the fragment loop recovers it), not an error.\n - `WriteAsync(path, type, handle, elements, data)` sends one Write Tag when it fits, otherwise Write Tag Fragmented chunks of `MaxRequestBytes - 12 - path.Length`, aligned down to the element width.\n - Non-zero final status \u21d2 `AbTransportException(reply.Status)`.\n- [ ] `FakeCipChannel` lets a test set `MaxRequestBytes`, records every request, and answers via a delegate. It is reused by Tasks 12 and 13.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \"FullyQualifiedName~RequestScheduler|FullyQualifiedName~LogixTagClient\"` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 10.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/RequestScheduler.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTagClient.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/RequestSchedulerTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipChannel.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTagClientTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \\\"FullyQualifiedName~RequestScheduler|FullyQualifiedName~LogixTagClient\\\"\", \"modelTier\": \"frontier\", \"model\": \"opus\", \"acceptanceCriteriaCount\": 9}\n```", "blockedBy": [ 7