a9fc933e3b
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
216 lines
102 KiB
JSON
216 lines
102 KiB
JSON
{
|
|
"planPath": "docs/plans/2026-10-07-native-ab-transport.md",
|
|
"specPath": "docs/plans/2026-10-07-native-ab-transport-design.md",
|
|
"tasks": [
|
|
{
|
|
"id": 1,
|
|
"subject": "Task 1: Ab.Transport project skeleton + shared contracts",
|
|
"status": "completed",
|
|
"description": "**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`.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatus.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbTransportException.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipWriter.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipReader.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipReply.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ICipChannel.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Wire/CipReplyTests.cs`\n- 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)\n- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add both projects next to the AbCip entries, lines 41-44 and 112-115)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj` and `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj` (add a `ProjectReference` to Ab.Transport)\n\n**Acceptance Criteria:**\n- [ ] `dotnet build ZB.MOM.WW.OtOpcUa.slnx` succeeds with 0 warnings and both new projects appear in the slnx.\n- [ ] `AbStatus.Ok == 0`, and every error member is negative.\n- [ ] `AbStatusCodes.FromCip` maps CIP general status as follows. It is aligned with the existing `AbCipStatusMapper.MapCipGeneralStatus`, so that existing mapping does not regress (review finding 13).\n - `0x00`/`0x0B` \u2192 `Ok`\n - `0x04`/`0x05`/`0x16` \u2192 `NotFound`\n - `0x06` \u2192 `Partial`\n - `0x08` \u2192 `Unsupported`\n - `0x0A` \u2192 `OutOfBounds`\n - `0x0C`/`0x10` \u2192 `DeviceStateConflict`\n - `0x0E`/`0x0F` \u2192 `NotAllowed`\n - `0x13` \u2192 `TooSmall`\n - `0x15` \u2192 `TooLarge`\n - `0xFF` + `0x2105` \u2192 `OutOfBounds`\n - `0xFF` + `0x2107` \u2192 `TypeMismatch`\n - everything else, including `0x1E`, \u2192 `BadReply`\n `0x1E` (embedded service error) is only meaningful on an MSP envelope, and the scheduler (T10) handles it before calling `FromCip`.\n- [ ] `AbStatusCodes.FromPccc` maps STS `0x00\u2192Ok`, `0x10\u2192Unsupported`, `0x20\u2192NotFound`, `0x30\u2192NotAllowed`, `0x40/0x50\u2192DeviceStateConflict`, and `0xF0` maps via EXT STS (`0x06/0x07\u2192NotFound` address problems, else `BadReply`).\n- [ ] `CipReply.Parse` round-trips a reply with 0, 1 and 2 extended-status words.\n- [ ] The driver projects reference Ab.Transport, and Ab.Transport references no OtOpcUa project.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests` \u2192 all pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 1.\n\n```json:metadata\n{\"files\": [\"ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport\", \"ZB.MOM.WW.OtOpcUa.slnx\", \"docs/plans/2026-10-07-native-ab-transport-gate-results.md\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatus.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbTransportException.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipReply.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ICipChannel.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipReader.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipWriter.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj\", \"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\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Wire/CipReplyTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 6}\n```"
|
|
},
|
|
{
|
|
"id": 2,
|
|
"subject": "Task 2: AbCip seam \u2014 decouple the driver from libplctag type",
|
|
"status": "completed",
|
|
"description": "**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.\n\n**Files:**\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs`, `LibplctagTagEnumerator.cs`, `LibplctagTemplateReader.cs` (wrap libplctag calls; translate)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs`\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs` (replace `MapLibplctagStatus(Status)` / `TryGetStatus(LibPlcTagException)` with `MapStatus(AbStatus)`, `MapStatus(int)`, and `MapException`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs` (`IsReachableStatus(AbStatus)`, `IsReachableException(AbTransportException)`; construct runtimes via an `IAbCipTagFactory` parameter that defaults to `new LibplctagTagFactory()`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:1,1186-1191` (catch `AbTransportException`)\n- Modify (tests): `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs`, `AbCipDriverProbeTests.cs`, `AbCipDriverReadTests.cs`, `AbCipStatusMapperTests.cs`, `AbCipDriverCodeReviewRegressionTests.cs` (line 326 uses `libplctag.Status.ErrorBadConnection`), `AbCipUnknownTagReadTests.cs`, `AbCipLoggingTests.cs` (line 91, same), `FakeAbCipTag.cs`. Run `grep -rln \"libplctag\" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --include=*.cs` and migrate **every** hit except `LibplctagStatusTranslationTests.cs` and the mapper parity oracle.\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs` (its `<see cref=\"AbCipStatusMapper.MapLibplctagStatus(int)\"/>` must become `MapStatus(int)`. A dangling cref is CS1574, which `TreatWarningsAsErrors` turns into a build error.)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs`\n\n**Acceptance Criteria:**\n- [ ] `grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/*.cs` lists only `LibplctagTagRuntime.cs`, `LibplctagTagEnumerator.cs`, `LibplctagTemplateReader.cs` and `LibplctagStatusTranslation.cs`.\n- [ ] `LibplctagTagRuntime.GetStatus()` returns `(int)AbStatus`, and every libplctag call that can throw `LibPlcTagException` rethrows `AbTransportException` with the translated status and the original as `InnerException`.\n- [ ] The translation table is the existing `MapLibplctagStatus` buckets re-expressed in `AbStatus`:\n - `Ok\u2192Ok`, `Pending\u2192Pending`, `ErrorTimeout\u2192Timeout`\n - `ErrorNotFound/ErrorNoMatch/ErrorBadDevice\u2192NotFound`\n - `ErrorNotAllowed\u2192NotAllowed`\n - `ErrorOutOfBounds\u2192OutOfBounds`, `ErrorTooLarge\u2192TooLarge`, `ErrorTooSmall\u2192TooSmall`\n - `ErrorUnsupported/ErrorNotImplemented\u2192Unsupported`\n - `ErrorPartial\u2192Partial`, `ErrorAbort\u2192Abort`\n - `ErrorBadReply\u2192BadReply`\n - every other member \u2192 `BadConnection`\n- [ ] `AbCipStatusMapper.MapStatus(AbStatus)` produces exactly the OPC UA codes the old mapper produced for the corresponding libplctag members. `TypeMismatch` maps to `BadTypeMismatch` and `DeviceStateConflict` to `BadDeviceFailure`. `Partial` and `BadReply` must still map to `BadCommunicationError`, as `ErrorPartial` / `ErrorBadReply` did.\n- [ ] The full existing `AbCip.Tests` suite is green, with the only changes being test fakes constructing `AbTransportException(AbStatus.X, ...)` instead of `LibPlcTagException(\"ErrorX\")` and setting `Status = (int)AbStatus.X`.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests` \u2192 all pass, then `grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/*.cs` \u2192 exactly the four `Libplctag*` files.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 2.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:1,1186-1191\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests && grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/*.cs\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 5}\n```",
|
|
"blockedBy": [
|
|
1
|
|
]
|
|
},
|
|
{
|
|
"id": 3,
|
|
"subject": "Task 3: AbLegacy seam \u2014 decouple the driver from libplctag t",
|
|
"status": "completed",
|
|
"description": "**Goal:** Do the same as Task 2 for AbLegacy. Only `LibplctagLegacyTagRuntime.cs` and `LibplctagLegacyStatusTranslation.cs` may reference libplctag.\n\n**Files:**\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs`\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyStatusMapper.cs` (`MapStatus(AbStatus|int)`, `MapException`; keep `MapPcccStatus`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs` (`IsReachableStatus(AbStatus)`, catch `AbTransportException`, optional `IAbLegacyTagFactory` parameter defaulting to `new LibplctagLegacyTagFactory()`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs` (lines ~318-325: `MapLibplctagStatus`\u2192`MapStatus`; log text \"transport status\"; any `LibPlcTagException` catch \u2192 `AbTransportException`)\n- Modify (tests): `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyEvictOnFailureTests.cs`, `AbLegacyReadWriteTests.cs` (line 73), `AbLegacyHostAndStatusTests.cs`, `AbLegacyBitRmwTests.cs` (line 232), `AbLegacyArrayTests.cs` (line 115), `FakeAbLegacyTag.cs`. Run `grep -rln \"libplctag\" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --include=*.cs` and migrate every hit except the translation test and the parity oracle. Also grep `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy` for `cref=\".*MapLibplctagStatus` and fix any match (CS1574).\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyStatusTranslationTests.cs`\n\n**Acceptance Criteria:**\n- [ ] `grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/*.cs` lists exactly `LibplctagLegacyTagRuntime.cs` and `LibplctagLegacyStatusTranslation.cs`.\n- [ ] 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.\n- [ ] `AbLegacyStatusMapper.MapStatus(AbStatus)` gives exactly the outputs the old `MapLibplctagStatus` gave for the corresponding members, proven by a parity table copied from the old switch before deleting it.\n- [ ] The full `AbLegacy.Tests` suite is green.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests` \u2192 all pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 3.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyStatusMapper.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyEvictOnFailureTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyStatusTranslationTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 4}\n```",
|
|
"blockedBy": [
|
|
1
|
|
]
|
|
},
|
|
{
|
|
"id": 4,
|
|
"subject": "Task 4: Capture golden frames (libplctag \u21c4 ab_server)",
|
|
"status": "completed",
|
|
"description": "**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.\n\n**Files:**\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/README.md` (how each capture was taken: commit, image tag, command)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/*.hex` (one file per exchange, as below)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFile.cs` (loader)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFileTests.cs`\n- Create: `scripts/ab-capture/capture.sh` (re-runnable recipe)\n\n**Acceptance Criteria:**\n- [ ] Each `.hex` file holds alternating `> ` (client\u2192server) and `< ` (server\u2192client) 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.\n- [ ] These captures exist and are non-empty:\n - `logix-register-session.hex`\n - `logix-forward-open.hex` (both a Large FO 0x5B and, if libplctag falls back or uses it, a 0x54)\n - `logix-read-dint.hex`, `logix-read-dint-array16.hex`, `logix-read-string.hex`, `logix-read-bool.hex`\n - `logix-write-dint.hex`, `logix-write-string.hex`\n - `logix-read-unknown-tag.hex` (CIP 0x04, via patch 0002)\n - `logix-tags-listing.hex` (`@tags`, via patch 0001)\n - `logix-forward-close.hex`\n - `micro800-read-dint.hex` (UCMM, no Forward Open)\n - `slc500-read-n7.hex`, `slc500-write-n7.hex`, `slc500-read-f8.hex`, `micrologix-read-n7.hex`, `plc5-read-n7.hex`\n- [ ] `CaptureFileTests` loads every `.hex` file, parses every frame header, and asserts that each frame's encapsulation length field equals its data length.\n- [ ] `Captures/README.md` records, for each file: the libplctag version (1.5.2), the ab_server image (`otopcua-ab-server:libplctag-release`), the compose profile, and the libplctag tag string used.\n- [ ] `Captures/README.md` has a **\"Routing observed\"** table, one row per family (controllogix, micro800, slc500, micrologix, plc5, plus logixpccc if reachable), with the columns: ForwardOpen yes/no \u00b7 CIP request wrapped in Unconnected Send yes/no \u00b7 route bytes \u00b7 requestor-id bytes (PCCC). Tasks 9, 12 and 14 read their `Routing` choice from this table.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter CaptureFile` \u2192 pass, and `ls tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/*.hex | wc -l` \u2192 \u2265 17.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 4.\n\n```json:metadata\n{\"files\": [\"scripts/ab-capture/capture.sh\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/*.hex\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFile.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFileTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/README.md\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter CaptureFile && ls tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/*.hex | wc -l\", \"modelTier\": \"frontier\", \"model\": \"opus\", \"acceptanceCriteriaCount\": 5}\n```",
|
|
"blockedBy": [
|
|
1
|
|
]
|
|
},
|
|
{
|
|
"id": 5,
|
|
"subject": "Task 5: EtherNet/IP encapsulation codec + `EncapsulationClie",
|
|
"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 <len u16>` 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 <connId u32>`, Connected Data item `B1 00 <len+2 u16> <seq u16>` 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<byte[] request, byte[]? reply>` 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,
|
|
4
|
|
]
|
|
},
|
|
{
|
|
"id": 6,
|
|
"subject": "Task 6: CIP path encoding + Logix type codes",
|
|
"status": "completed",
|
|
"description": "**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.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTypeCode.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipPathTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTypeCodeTests.cs`\n\n**Acceptance Criteria:**\n- [ ] `CipPath.EncodeRoute(\"1,0\")` \u2192 `01 00`. `EncodeRoute(\"\")` \u2192 empty. `EncodeRoute(\"1,0,2,10.0.0.5\")` \u2192 `01 00 12 08 31 30 2E 30 2E 30 2E 35`. That is port 2 with the extended-link-address flag `0x10 | 2` = `0x12`, length 8, the ASCII text, and no pad because the length is even. A 9-character address gets one pad byte. Port numbers \u2265 15 use the extended-port form `0x0F, u16 port`.\n- [ ] `CipPath.EncodeSymbolic(\"TestDINT\")` \u2192 `91 08 54 65 73 74 44 49 4E 54`. An odd-length name gets a pad byte after the name.\n- [ ] `EncodeSymbolic(\"Program:Main.Arr[3].Val\")` \u2192 `91 0C \"Program:Main\"`, then `91 03 \"Arr\" 00`, then element `28 03`, then `91 03 \"Val\" 00`.\n- [ ] Element segments use `28 xx` for indices < 256, `29 00 lo hi` for indices < 65536, and `2A 00 b0 b1 b2 b3` above that. A multi-dimensional `[1,2]` emits two element segments.\n- [ ] A trailing purely numeric member (`Tag.5`, the bit selector) is **not** encoded. `EncodeSymbolic` throws `ArgumentException`, and `CipPath.SplitBitSelector(\"Tag.5\")` returns `(\"Tag\", 5)`. Bit selection is the runtime's job (Task 12).\n- [ ] `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`).\n- [ ] `CipPath.WithPathSize(segments)` prefixes the size in 16-bit words.\n- [ ] `LogixTypeCode` has these constants: `Bool=0xC1, SInt=0xC2, Int=0xC3, DInt=0xC4, LInt=0xC5, USInt=0xC6, UInt=0xC7, UDInt=0xC8, ULInt=0xC9, Real=0xCA, LReal=0xCB, Struct=0x02A0, BoolArray=0xD3`. `ByteWidth(code)` returns 1/1/2/4/8/1/2/4/8/4/8/4.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \"FullyQualifiedName~CipPath|FullyQualifiedName~LogixTypeCode\"` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 6.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTypeCode.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipPathTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTypeCodeTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \\\"FullyQualifiedName~CipPath|FullyQualifiedName~LogixTypeCode\\\"\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 8}\n```",
|
|
"blockedBy": [
|
|
1
|
|
]
|
|
},
|
|
{
|
|
"id": 7,
|
|
"subject": "Task 7: CIP service codecs (Connection Manager, MSP, Logix R",
|
|
"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 <serial> <vendor> <origSerial> <pathWords> 00 <route + MessageRouter>`.\n- [ ] **MSP.** `MultipleServicePacket.Build(IReadOnlyList<byte[]> 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 <pathWords> <path> <elements u16>`.\n - `ReadTagFragmented(path, elements, offset)` \u2192 `52 \u2026 <elements u16> <offset u32>`.\n - `WriteTag(path, typeCode, structHandle?, elements, data)` \u2192 `4D \u2026 <type u16> [<handle u16> when type == 0x02A0] <elements u16> <data>`.\n - `WriteTagFragmented` \u2192 `53 \u2026 <type> [<handle>] <elements> <offset u32> <data>`.\n - `ParseReadReply(CipReply)` \u2192 `(ushort TypeCode, ushort? StructHandle, ReadOnlyMemory<byte> 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 <start u16>`, 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 <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).\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 <id u16> <offset u32> <bytes u16>`, 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,
|
|
6
|
|
]
|
|
},
|
|
{
|
|
"id": 8,
|
|
"subject": "Task 8: PCCC codec (Execute PCCC, SLC/MicroLogix typed logic",
|
|
"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 <vendor u16> <serial u32> <pccc bytes>`.\n- [ ] `PcccServices.SlcTypedRead(tns, addr, byteCount)` \u2192 PCCC `0F 00 <tns u16> A2 <byteCount u8> <file u8*> <type u8> <element u8*> <sub u8*>`, where `u8*` is a single byte when < 255, else `FF lo hi`.\n- [ ] `PcccServices.SlcTypedWrite(tns, addr, data)` \u2192 `0F 00 <tns> AA <byteCount> <file*> <type> <element*> <sub*> <data>`.\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 <tns u16> 68 <packetOffset u16 0> <totalTrans u16 elements> <PLC-5 system address> <size u16 elements>`.\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 <tns> 67 <offset u16> <total u16> <sysaddr> <type/size param> <data>`.\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
|
|
]
|
|
},
|
|
{
|
|
"id": 9,
|
|
"subject": "Task 9: CipConnection + DeviceSessionPool (routing modes, ke",
|
|
"status": "completed",
|
|
"description": "**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.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/DeviceKey.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionOptions.cs` (includes the `CipRouting` enum)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs` (implements `ICipChannel`)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ForwardOpenThrottle.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/DeviceSessionPool.cs` (includes `ConnectionLease`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj` (`InternalsVisibleTo` for `ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests`, `ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests`, `ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests`)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ForwardOpenThrottleTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/DeviceSessionPoolTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipTarget.cs` (a `FakeEipServer.Handler` that answers Forward Open / Forward Close / Unconnected Send / Direct / connected data, and counts each)\n\n**Acceptance Criteria:**\n- [ ] `DeviceKey(string Host, int Port, string CipPath, string PlcAttribute)` is a record with case-insensitive `Host`/`PlcAttribute` equality.\n- [ ] **`ConnectionOptions`:**\n - `Routing` (`CipRouting { Connected, UnconnectedSend, Direct }`) and `AllowPacking` (bool).\n - `Timeout` and `ConnectionSize` (int).\n - `MinForwardOpenInterval` (default 1 s).\n - `RpiMicros` (default 2 000 000) and `TimeoutMultiplier` (default 1 \u21d2 \u00d78, so connection expiry = 16 s).\n - `KeepAliveInterval` (default = RPI \u00d7 8 / 2 = 8 s).\n - `VendorId` (`0x1337`) and `OriginatorSerial` (random per process).\n- [ ] **Routing semantics (B3):**\n - `Connected`:\n - On first use: TCP connect, RegisterSession, Forward Open. When `ConnectionSize > 511` that is a Large FO (0x5B); otherwise a regular one.\n - If `ShouldFallBackToRegularOpen` (T7) is true for the reply, it retries **once**, in the same throttled attempt, with a regular open at `Math.Min(ConnectionSize, 504)`.\n - Requests go via `SendUnitDataAsync`. The client assigns the sequence (T5).\n - `MaxRequestBytes = negotiatedSize - 2`.\n - `UnconnectedSend`: requests go via SendRRData wrapped in Unconnected Send 0x52 with the route. `MaxRequestBytes = 504 - (12 + route.Length)`.\n - `Direct`: requests go via SendRRData carrying the CIP request bare, even when a CIP path is configured; the path is ignored. `MaxRequestBytes = 504`.\n- [ ] **Reconnect:** when the client is faulted (timeout or I/O, never caller cancellation \u2014 see T5), the next `SendAsync` reconnects through the throttle. The failing call itself throws its original exception.\n- [ ] **Keep-alive (review finding 8, spec \u00a79).** In `Connected` mode, a timer sends Get Attribute Single (`0E 03 20 01 24 01 30 01`: Identity, instance 1, attribute 1 = Vendor ID) through the connection whenever no request has been sent for `KeepAliveInterval`.\n - A keep-alive failure faults the connection like any other.\n - Keep-alives count towards `RequestsSent`.\n - Test with fake time: no keep-alive while traffic flows; exactly one after an idle `KeepAliveInterval`.\n- [ ] **No null-race (finding 16).** The open path returns the `(client, connection)` pair it established, and `SendAsync` uses that pair rather than re-reading fields after the lock.\n- [ ] **Clean disposal.** Disposal never disposes a `SemaphoreSlim` that waiters may still be queued on. It sets a `_disposed` flag checked under the lock, and later callers get `AbTransportException(Abort)`.\n- [ ] **`ForwardOpenThrottle`** (per `DeviceKey`; the pool keeps throttles for the process lifetime, so a reconnect after the last release is still limited):\n - At most 1 open attempt in flight.\n - An attempt within `MinForwardOpenInterval` of the previous **attempt start** waits for the remainder. If the caller's token fires first, it throws `AbTransportException(BadConnection, \"Forward Open throttled\")`.\n - It uses an injected `TimeProvider`.\n- [ ] **Storm test:** against a `FakeCipTarget` refusing every Forward Open, 50 concurrent `SendAsync` callers over 3 s of fake time produce **\u2264 4** Forward Open frames.\n- [ ] **`DeviceSessionPool`:**\n - `Acquire(DeviceKey, ConnectionOptions) \u2192 ConnectionLease`. `ConnectionLease : IAsyncDisposable` exposes `ICipChannel Channel` and `RequestScheduler Scheduler`.\n - **All leases for one key share one `CipConnection` and one `RequestScheduler`** (B1). The scheduler is built as `new RequestScheduler(connection, options.AllowPacking)`. A second `Acquire` with different `ConnectionOptions` for the same key reuses the first entry's options; that is logged once and pinned by a test.\n - The last lease's dispose disposes the scheduler, sends Forward Close (`Connected` only) and then UnRegisterSession.\n - A later acquire on the same key builds new instances.\n - Thread-safe: 100 parallel acquire/dispose tasks pass.\n- [ ] `DeviceSessionPool.Shared` is the process-wide instance; tests build their own.\n- [ ] **Diagnostics for the gates (Tasks 18 and 21):** `internal static int ForwardOpenCount` (one increment per Forward Open frame sent, including the fallback) and `internal static int RequestsSent` (one per channel send, keep-alives included), both via `Interlocked`.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \"FullyQualifiedName~CipConnection|FullyQualifiedName~ForwardOpenThrottle|FullyQualifiedName~DeviceSessionPool\"` \u2192 pass; the storm test passes 20/20 consecutive runs.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 9.\n\n```json:metadata\n{\"files\": [\"ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests\", \"ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests\", \"ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionOptions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/DeviceKey.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ForwardOpenThrottle.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/DeviceSessionPool.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ForwardOpenThrottleTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/DeviceSessionPoolTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipTarget.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter \\\"FullyQualifiedName~CipConnection|FullyQualifiedName~ForwardOpenThrottle|FullyQualifiedName~DeviceSessionPool\\\"\", \"modelTier\": \"frontier\", \"model\": \"opus\", \"acceptanceCriteriaCount\": 12}\n```",
|
|
"blockedBy": [
|
|
5,
|
|
7,
|
|
10
|
|
]
|
|
},
|
|
{
|
|
"id": 10,
|
|
"subject": "Task 10: RequestScheduler \u2014 MSP coalescing + fragmentation",
|
|
"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<CipReply> 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
|
|
]
|
|
},
|
|
{
|
|
"id": 11,
|
|
"subject": "Task 11: libplctag-confinement guard",
|
|
"status": "completed",
|
|
"description": "**Goal:** A source-scan test that fails if any production `.cs` file outside the libplctag adapters references `libplctag`, so the eventual deletion stays mechanical.\n\n**Files:**\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/LibplctagConfinementGuardTests.cs`\n\n**Acceptance Criteria:**\n- [ ] The test walks up from `AppContext.BaseDirectory` to the directory containing `ZB.MOM.WW.OtOpcUa.slnx`, then scans every `*.cs` under `src/` (excluding `bin`/`obj`) for **code** references: `using libplctag` or `libplctag.(Tag|Status|PlcType|Protocol|LibPlcTagException|NativeImport)`. Prose such as the CLI help string `\"via libplctag. Second of six\u2026\"` (`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).\n- [ ] Allowed files: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Libplctag*.cs` and `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/Libplctag*.cs` only. Comments and XML docs are ignored by stripping `//` line comments before matching.\n- [ ] The failure message lists each offending file and line.\n- [ ] A second fact, `Prose_mentions_of_libplctag_do_not_trip_the_guard`, runs the regex against the literal `\"\u2026via libplctag. Second of six driver CLIs\"` and asserts no match.\n- [ ] **Falsifiability check:** temporarily add `using libplctag;` to `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs`, run the test and see it go red, then revert. Record that you did this in the commit message.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter LibplctagConfinementGuard` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 11.\n\n```json:metadata\n{\"files\": [\"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/LibplctagConfinementGuardTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter LibplctagConfinementGuard\", \"modelTier\": \"mechanical\", \"model\": \"haiku\", \"acceptanceCriteriaCount\": 5}\n```",
|
|
"blockedBy": [
|
|
2,
|
|
3
|
|
]
|
|
},
|
|
{
|
|
"id": 12,
|
|
"subject": "Task 12: `NativeAbCipTagRuntime` + factory",
|
|
"status": "completed",
|
|
"description": "**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.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagFactory.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipDevice.cs` (maps `AbCipTagCreateParams` \u2192 `DeviceKey` + `ConnectionOptions`; shared with Task 13)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs` (add trailing optionals `AbCipDataType? AuthoredType = null, bool UseConnectedMessaging = true` to `AbCipTagCreateParams`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs` (pass `UseConnectedMessaging: profile.SupportsConnectedMessaging`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs` (`EnsureTagRuntimeAsync` passes `AuthoredType: def.DataType`; `DeviceState`'s create-params builder passes `UseConnectedMessaging: Profile.SupportsConnectedMessaging`)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs`\n- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj` (add a `ProjectReference` to Ab.Transport and `<Compile Include=\"..\\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\\Fakes\\FakeCipChannel.cs\" Link=\"Fakes\\FakeCipChannel.cs\" />`.; source-linking compiles the fake into this assembly, so it stays `internal`)\n\n**Acceptance Criteria:**\n- [ ] `NativeAbCipDevice.Key(p)` = `new DeviceKey(p.Gateway, p.Port > 0 ? p.Port : 44818, p.CipPath, p.LibplctagPlcAttribute)`.\n- [ ] `NativeAbCipDevice.Options(p)`:\n - `Timeout = p.Timeout`, `ConnectionSize = p.ConnectionSize`, `AllowPacking = p.AllowPacking`.\n - **`Routing`:** if `p.UseConnectedMessaging` is false, use `UnconnectedSend` when `p.CipPath` is non-empty and `Direct` when it is empty. Otherwise use `Connected`.\n - `UseConnectedMessaging` is a new trailing optional `bool UseConnectedMessaging = true` on `AbCipTagCreateParams`. It is set at every construction site from `AbCipPlcFamilyProfile.SupportsConnectedMessaging`, so the family profile stays the single authority and string-matching `\"micro800\"` is not needed (review finding 18). The sites are `AbCipDriver` `DeviceState` (around `AbCipDriver.cs:1690-1700`, where `AllowPacking`/`ConnectionSize` are already merged) and `AbCipDriverProbe`; find any others with `grep -rn \"new AbCipTagCreateParams\" src`.\n - Cross-check the result against the Task 4 \"Routing observed\" table for controllogix and micro800, and record any mismatch in the test comment.\n- [ ] **Initialization and reads:**\n - `InitializeAsync` acquires the lease from `DeviceSessionPool.Shared` and builds a per-runtime `LogixTagClient` over **the lease's shared `Scheduler` and `Channel`**. It must never construct its own `RequestScheduler`: that would defeat MSP coalescing across tags (B1). It encodes the path once, splitting off a trailing bit selector with `CipPath.SplitBitSelector`; that is stored as the runtime's implicit bit.\n - It performs **one read** to prove the tag exists. That matches libplctag, whose `InitializeAsync` errors for an unknown tag; the driver's 05/STAB-8 logic depends on create throwing `NotFound`.\n - `ReadAsync` reads `p.ElementCount` elements, stores the data, `TypeCode` and `StructHandle`, and sets status `Ok`. On `AbTransportException` it stores the exception's status **and rethrows**, matching libplctag's exception-on-failure behaviour. `GetSize()` returns the data length.\n- [ ] **Decode** is byte-identical to `LibplctagTagRuntime` for every `AbCipDataType` (Bool/SInt/USInt/Int/UInt/DInt/UDInt/LInt/ULInt/Real/LReal/String/Dt). Specifically:\n - Bool with a bit index \u21d2 bit `n` of the little-endian buffer.\n - Bool without \u21d2 `byte[0] != 0`.\n - String \u21d2 Logix STRING `u32 LEN` + `DATA`, ASCII, `LEN` clamped to the data length \u2212 4.\n - `DecodeArray` uses stride = `data.Length / count` when it divides evenly, else the static width (the same `ResolveArrayStride` rule; call `LibplctagTagRuntime.ResolveArrayStride` with `elementSize: null` to share it).\n - Bool arrays are bit-packed (bit `i` of the buffer).\n- [ ] **Type check:** after a read, if `TypeCode` is atomic and differs from the authored type's Logix code, `GetStatus()` returns `(int)AbStatus.TypeMismatch` and `Decode*` returns null. The mapping is `AbCipDataType` \u2192 `LogixTypeCode`, with `Dt \u2192 DInt`; `String`/`Structure` are exempt. Exemptions:\n - (a) A BOOL read with a bit index against a DINT host is **not** a mismatch.\n - (b) **Authored `Bool` with returned `0xD3`** (the Logix packed BOOL-array type) is **not** a mismatch (review finding 11).\n - Tests cover both exemptions, plus a positive mismatch.\n- [ ] **Encode/write:**\n - `EncodeValue` fills a managed write buffer with the same encodings as `LibplctagTagRuntime.EncodeValue`, including the same `NotSupportedException` cases.\n - `WriteAsync` writes with type = the last read `TypeCode` if known, else the authored type's code.\n - For String, it uses `Struct` + the last `StructHandle`. If none is known it performs a read first; that read happens inside `WriteAsync`, under the driver's existing per-runtime lock.\n - The STRING write payload is `u32 len` + DATA padded to the capacity learned from the last read's data length \u2212 4.\n- [ ] `Dispose` releases the lease, waiting for at most 2 s (`lease.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(2))`). It must not fire and forget: an immediate re-init would otherwise open a second session to the press while the old Forward Close is still in flight (review finding 22). It never disposes the shared scheduler; the pool does that on the last release.\n- [ ] `NativeAbCipTagFactory : IAbCipTagFactory` creates `NativeAbCipTagRuntime`.\n- [ ] Test seam: `internal static NativeAbCipTagRuntime ForTest(ICipChannel channel, AbCipTagCreateParams p)` builds a runtime over the given channel with a private `RequestScheduler(channel, p.AllowPacking)` (test-only; no pool lease). Production code always goes through the factory and `DeviceSessionPool.Shared`.\n- [ ] **Unit tests** (against `FakeCipChannel` via `ForTest`):\n - decode parity for each type, using a buffer captured from `LibplctagTagRuntime` semantics (hard-coded bytes);\n - the TypeMismatch case (REAL returned for a DInt-authored tag);\n - an unknown tag \u21d2 `InitializeAsync` throws `AbTransportException(NotFound)`;\n - a STRING write sends `Struct` + the handle learned from the prior read;\n - a 1-element array is read as an array (`IsArray`).\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter NativeAbCipTagRuntime` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 12.\n\n```json:metadata\n{\"files\": [\"ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipDevice.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagFactory.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter NativeAbCipTagRuntime\", \"modelTier\": \"frontier\", \"model\": \"opus\", \"acceptanceCriteriaCount\": 10}\n```",
|
|
"blockedBy": [
|
|
2,
|
|
9
|
|
]
|
|
},
|
|
{
|
|
"id": 13,
|
|
"subject": "Task 13: Native AbCip symbol enumerator + template reader",
|
|
"status": "completed",
|
|
"description": "**Goal:** Native `IAbCipTagEnumerator` and `IAbCipTemplateReader` implementations whose output buffers feed the **unchanged** decoders.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/SymbolListingEnumerator.cs` (the transport-neutral listing loop moved out of `LibplctagTagEnumerator.cs`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs` (keeps only `LibplctagListingReader` + `LibplctagTagEnumeratorFactory`)\n- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/` files referencing `LibplctagTagEnumerator` (rename to `SymbolListingEnumerator`)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagEnumerator.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTemplateReader.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagEnumeratorTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTemplateReaderTests.cs`\n\n**Acceptance Criteria:**\n- [ ] **Enumerator split.** The current `LibplctagTagEnumerator` class (its listing loop has no libplctag dependency apart from its default constructor) is renamed **`SymbolListingEnumerator`**, keeping its `Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> readListing` constructor seam. The libplctag default reader moves into `LibplctagTagEnumerator.cs` as `LibplctagListingReader`, so the Task 11 guard still allows it. `LibplctagTagEnumeratorFactory` returns `new SymbolListingEnumerator(LibplctagListingReader.ReadAsync)`.\n- [ ] **Native listing reader.** `NativeAbCipTagEnumerator` = `new SymbolListingEnumerator(NativeListingReader.ReadAsync)`, wrapped in a class for the factory. Its reader:\n - `\"@tags\"` \u21d2 pages `SymbolObjectServices.GetInstanceAttributeList(null, start)` from instance 0, appending entry bytes. While the reply is `More`, it continues from `LastInstance + 1`.\n - `\"Program:X.@tags\"` \u21d2 the same with `programName: \"X\"`.\n - It returns the concatenated bytes.\n- [ ] `NativeAbCipTemplateReader.ReadAsync(params, id)`:\n - `GetAttributes(id)` gives `defWords`, `structBytes`, `members`, `handle`.\n - It then sends `ReadTemplate(id, offset, chunk)` repeatedly, with `chunk = Math.Min(remaining, MaxRequestBytes - 16)`, where the total = `defWords * 4 - 23` (the CIP-documented Read Template length), advancing by each reply's data length until the status is 0. Status `0x06` continues.\n - It returns `TemplateObjectServices.BuildDecoderBuffer(...)`.\n- [ ] Unit tests feed the **real controller captures** through the native path. The fake channel serves, as reply data, the bytes contained in `CipTemplateObjectWireCaptureTests` (rev 34.11 press). The test asserts that `CipTemplateObjectDecoder.Decode(nativeBuffer)` equals `Decode(capturedLibplctagBuffer)`, member by member.\n - 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.\n - Do the same for `CipSymbolObjectRealControllerCaptureTests` entries: split them across two pages with a `0x06` status between, and assert the decoded tag list is identical.\n- [ ] Both factories (`NativeAbCipTagEnumeratorFactory`, `NativeAbCipTemplateReaderFactory`) exist and lease from `DeviceSessionPool.Shared` via `NativeAbCipDevice`. They send through **the lease's shared `Scheduler`**, never a private one, and dispose the lease (bounded 2 s wait, as in T12) when the enumeration or read completes.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter \"FullyQualifiedName~NativeAbCipTagEnumerator|FullyQualifiedName~NativeAbCipTemplateReader|FullyQualifiedName~Discovery\"` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 13.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagEnumerator.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTemplateReader.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/SymbolListingEnumerator.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagEnumeratorTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTemplateReaderTests.cs\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter \\\"FullyQualifiedName~NativeAbCipTagEnumerator|FullyQualifiedName~NativeAbCipTemplateReader|FullyQualifiedName~Discovery\\\"\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 4}\n```",
|
|
"blockedBy": [
|
|
2,
|
|
9
|
|
]
|
|
},
|
|
{
|
|
"id": 14,
|
|
"subject": "Task 14: `NativeAbLegacyTagRuntime` + factory",
|
|
"status": "completed",
|
|
"description": "**Goal:** A native `IAbLegacyTagRuntime` over PCCC (Task 8) carried in Execute PCCC through a `DeviceSessionPool` lease.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs`\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagFactory.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs`\n- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj` (reference Ab.Transport + link `FakeCipChannel.cs`, as in Task 12)\n\n**Acceptance Criteria:**\n- [ ] **Connection:**\n - `DeviceKey` is built from `AbLegacyTagCreateParams` the same way as Task 12 (default port 44818).\n - **`Routing` comes from the Task 4 \"Routing observed\" table (B3), per family:**\n - Expected: `slc500`/`micrologix`/`plc5` \u21d2 **`Direct`** (libplctag sends Execute PCCC bare in SendRRData even though the driver forces `EffectiveCipPath = \"1,0\"`, `AbLegacyDriver.cs:864`); `logixpccc` \u21d2 **`Connected`**.\n - **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.\n - `ConnectionSize = 504`, `AllowPacking = false` (PCCC requests are never MSP-packed).\n - Send through the lease's shared `Scheduler`, never a private one (B1).\n- [ ] **Address handling:**\n - `AbLegacyAddress.TryParse(p.TagName)` \u21d2 `PcccAddress(FileLetter, FileNumber, WordNumber, sub)`. The sub-element comes from `ResolveSubElement`. A `BitIndex` is kept for decode only, because reads are word reads.\n - 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.\n - Element count = `p.ElementCount`.\n- [ ] **Read and write:**\n - `ReadAsync` dispatches by `plc` attribute: `slc500`/`micrologix`/`logixpccc` \u21d2 `SlcTypedRead`; `plc5` \u21d2 `Plc5TypedRead`, or `AbTransportException(Unsupported)` when `PcccServices.Plc5Verified` is false (Task 8).\n - The TNS increments per request.\n - **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).\n - On failure it stores the status and rethrows.\n - `InitializeAsync` performs one read, for the same `NotFound`-on-create reason as Task 12.\n- [ ] **Decode** is identical to `LibplctagLegacyTagRuntime`:\n - Bit with index \u21d2 bit of the LE word (`/N` up to 15, or up to 31 for L).\n - Bit without \u21d2 `(word & 1) != 0`.\n - Int/AnalogInt \u21d2 `short`\u2192`int`; Long \u21d2 `int`; Float \u21d2 `float`.\n - String \u21d2 the SLC ST layout: `u16 LEN` + 82 data bytes **with each byte pair swapped**. \u26a0\ufe0f Verify this byte order against `slc500` ST data in ab_server. If ab_server does not seed ST, test against a hand-built buffer and flag it in Task 20 docs as unverified.\n - Timer/Counter/Control element without a sub-element \u21d2 `GetInt32(0)` parity (the first 4 bytes, LE).\n - `DecodeArray` uses the offsets documented in `LibplctagLegacyTagRuntime.DecodeArray`.\n- [ ] **Encode** follows `LibplctagLegacyTagRuntime.EncodeValue` case by case, with the same `NotSupportedException` for bit-with-index. `WriteAsync` \u21d2 `SlcTypedWrite` (or PLC-5 / Unsupported).\n- [ ] **Unit tests:**\n - N7:0 read decode;\n - F8 read;\n - B3:0/5 decode;\n - an N7 array of 5;\n - T4:0.ACC sub-element addressing (bytes on the wire);\n - STS 0x20 \u21d2 `NotFound` on initialize;\n - a write of N7:0 = 42 produces the expected PCCC bytes.\n\n**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter NativeAbLegacyTagRuntime` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 14.\n\n```json:metadata\n{\"files\": [\"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagFactory.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj\"], \"verifyCommand\": \"dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter NativeAbLegacyTagRuntime\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 6}\n```",
|
|
"blockedBy": [
|
|
3,
|
|
8,
|
|
9
|
|
]
|
|
},
|
|
{
|
|
"id": 15,
|
|
"subject": "Task 15: AbCip `Transport` switch (options, factory, probe, ",
|
|
"status": "completed",
|
|
"description": "**Goal:** One `Transport` option selects the native or libplctag factory set everywhere AbCip creates a transport object: the driver, Test Connect, and the CLI.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTransport.cs`\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs` (add `public AbCipTransport Transport { get; init; } = AbCipTransport.Libplctag;`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs` (DTO `string? Transport`; `ParseEnum<AbCipTransport>(dto.Transport, \u2026, fallback: AbCipTransport.Libplctag)`)\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTransportFactories.cs`\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:145-155` (null factories \u21d2 `AbCipTransportFactories.For(options.Transport)`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs` (use `AbCipTransportFactories.For(opts.Transport).Tags`)\n- Modify: `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/` (`--transport native|libplctag`, default `libplctag`; help text \"via libplctag\" \u2192 \"via libplctag or the native transport\")\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs`\n- Modify: `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/` (add a `--transport` parse test)\n\n**Acceptance Criteria:**\n- [ ] `public enum AbCipTransport { Libplctag, Native }`.\n- [ ] `AbCipTransportFactories.For(t)` returns `(IAbCipTagFactory Tags, IAbCipTagEnumeratorFactory Enumerators, IAbCipTemplateReaderFactory Templates)`: the native trio for `Native`, the libplctag trio for `Libplctag`.\n- [ ] Explicitly injected factories (tests) still win over the option.\n- [ ] JSON configs: `\"transport\": \"Native\"` \u21d2 Native; `\"transport\": \"native\"` \u21d2 Native (case-insensitive, as with the existing `ParseEnum`); absent \u21d2 Libplctag; `\"transport\": \"Bogus\"` \u21d2 the same failure the existing `ParseEnum` raises for a bad `PlcFamily` (do not invent a new behaviour).\n- [ ] **Reinit does NOT swap transports, by design (review finding 19).** `AbCipDriver.ReinitializeAsync` (`AbCipDriver.cs:401`) is `ShutdownAsync` + `InitializeAsync` over constructor-held factories, so a transport change in the reinit JSON is inert. Do **not** invent factory swapping:\n - Document in the option's XML doc and in `docs/drivers/AbCip.md` (T20) that changing `transport` takes effect when the driver instance is recreated, which a deploy that changes the driver config does.\n - Pin the current behaviour with a test, `Reinit_with_a_changed_transport_keeps_the_constructed_transport`, so a future change is deliberate.\n - Before writing that test, confirm how a deploy applies a changed `DriverConfig`: `grep -rn \"ReinitializeAsync\\|CreateInstance\" src/Server src/Core | grep -i driver`. If deploys recreate the instance, say so in the doc. If they call `ReinitializeAsync`, raise it as a follow-up in the commit message rather than widening this task.\n- [ ] The **probe** deserializes with its own `JsonStringEnumConverter`, so a bad `\"transport\"` reports \"Config JSON is invalid\", not the `ParseEnum` message. That is acceptable; assert that exact behaviour in the probe test rather than the driver's.\n- [ ] The probe with `Transport: Native` constructs a `NativeAbCipTagRuntime`. Prove it with a test that uses an unreachable `127.0.0.1:1` endpoint and asserts the failure message comes from the native path (\"TCP connect \u2026 failed\"), not libplctag's.\n- [ ] The CLI `--transport native` reaches `AbCipDriverOptions.Transport`.\n\n**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` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 15.\n\n```json:metadata\n{\"files\": [\"src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTransport.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:145-155\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTransportFactories.cs\", \"tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs\"], \"verifyCommand\": \"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\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 8}\n```",
|
|
"blockedBy": [
|
|
12,
|
|
13
|
|
]
|
|
},
|
|
{
|
|
"id": 16,
|
|
"subject": "Task 16: AbLegacy `Transport` switch (options, factory, prob",
|
|
"status": "completed",
|
|
"description": "**Goal:** Do the same as Task 15 for AbLegacy.\n\n**Files:**\n- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTransport.cs` (`public enum AbLegacyTransport { Libplctag, Native }`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs` (`Transport` property, default `Libplctag`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs` (DTO + parse)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs:65-71` (`_tagFactory = tagFactory ?? (options.Transport == AbLegacyTransport.Native ? new NativeAbLegacyTagFactory() : new LibplctagLegacyTagFactory());`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs`\n- Modify: `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/` (`--transport`)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs`\n- Modify: `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/`\n\n**Acceptance Criteria:** the same five criteria as Task 15, applied to AbLegacy types:\n- [ ] `\"transport\": \"Native\"` and `\"native\"` select the native factory, absent selects libplctag, and a bad value fails the way an existing bad enum field does.\n- [ ] An injected factory wins over the option.\n- [ ] The probe with Native uses the native path; prove it with the unreachable-endpoint message test.\n- [ ] The CLI `--transport native` reaches the options.\n- [ ] A reinit with a changed transport keeps the constructed transport (`AbLegacyDriver` reinit has the same Shutdown+Initialize shape), pinned by the same kind of test and documentation as Task 15.\n\n**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` \u2192 pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 16.\n\n```json:metadata\n{\"files\": [\"src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTransport.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs:65-71\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs\", \"tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs\"], \"verifyCommand\": \"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\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 6}\n```",
|
|
"blockedBy": [
|
|
14
|
|
]
|
|
},
|
|
{
|
|
"id": 17,
|
|
"subject": "Task 17: AbCip + AbLegacy integration suites on both transpo",
|
|
"status": "completed",
|
|
"description": "**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.\n\n**Files:**\n- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs` and every other test in that project that constructs `AbCipDriverOptions` (`grep -rln \"new AbCipDriverOptions\" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests`)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs`\n- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs` and siblings\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs`\n- Modify: `docs/plans/2026-10-07-native-ab-transport-gate-results.md` (created empty by Task 1; this task fills only its `## ab_server suites (T17)` section)\n\n**Acceptance Criteria:**\n- [ ] `TransportMatrix.Profiles` yields `(profile, transport)` for every profile \u00d7 both transports. The theories switch from `MemberData(nameof(Profiles))` to `MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))`, and options get `Transport = transport`.\n- [ ] **The AbLegacy matrix is its own class** in the AbLegacy integration project, using `AbLegacyTransport`, and it **preserves the existing `AB_LEGACY_COMPOSE_PROFILE` filter** that `AbLegacyReadSmokeTests.Profiles` applies today (`AbLegacyReadSmokeTests.cs:26-35`): it must filter `KnownProfiles.All` by that env var before crossing with transports. The AbCip snippet below does not apply to AbLegacy verbatim (review finding 20).\n- [ ] Run against `10.100.0.35`, one compose profile at a time:\n - AbCip: `controllogix`, `compactlogix`, `micro800`, `guardlogix`.\n - AbLegacy: `slc500`, `micrologix`, `plc5`.\n - **For each profile, the set of passing tests under `Native` \u2287 the set under `Libplctag`.**\n- [ ] Any native-only failure is fixed in the transport task's code (reopen that task's files) and is not skipped. The only allowed exception is the PLC-5 `Unsupported` path from Task 8, marked `Skip = \"PLC-5 framing unverified \u2014 see Task 8\"` **only if** Task 8 set `Plc5Verified = false`.\n- [ ] The results doc records, under its `## ab_server suites (T17)` heading, per profile \u00d7 transport, the pass/fail/skip counts plus the `ab_server` image tag and the commit.\n\n**Verify:** for each profile P (AbCip stack directory as established in Task 4's `Captures/README.md`; AbLegacy at `~/otopcua-ablegacy` with `AB_LEGACY_COMPOSE_PROFILE=P`): `ssh dohertj2@10.100.0.35 'cd <abcip stack dir> && 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\"` \u2192 the Native pass set \u2287 the Libplctag pass set (compare the two trx files with `grep -o 'testName=\"[^\"]*\" .*outcome=\"[^\"]*\"'`).\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 17.\n\n```json:metadata\n{\"files\": [\"docs/plans/2026-10-07-native-ab-transport-gate-results.md\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs\"], \"verifyCommand\": \"AB_SERVER_ENDPOINT=10.100.0.35:44818 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --logger \\\"trx;LogFileName=P.trx\\\" && grep -o 'testName=\\\"[^\\\"]*\\\" .*outcome=\\\"[^\\\"]*\\\"'\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 5}\n```",
|
|
"blockedBy": [
|
|
15,
|
|
16
|
|
]
|
|
},
|
|
{
|
|
"id": 18,
|
|
"subject": "Task 18: Differential harness (libplctag vs native, same tar",
|
|
"status": "completed",
|
|
"description": "**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.\n\n**Files:**\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs`\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialTarget.cs`\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs` and `NativeAbCipTagRuntime.cs` (add `internal ReadOnlyMemory<byte> RawBuffer`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj` (`InternalsVisibleTo` for `ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests`)\n\n**Acceptance Criteria:**\n- [ ] **Env gating:**\n - `AB_DIFF_ENDPOINT` (`host:port`), `AB_DIFF_PATH` (default `1,0`), `AB_DIFF_FAMILY` (default `CompactLogix`).\n - `AB_DIFF_TAGS`: an optional comma list. Absent \u21d2 every atomic scalar, the first 20 arrays and the first 20 UDT members from browse.\n - `AB_DIFF_MAX_TAGS` (default 200).\n - Absent `AB_DIFF_ENDPOINT` \u21d2 every test reports **skipped**, with the reason naming the variable.\n- [ ] **The suite is read-only by construction.** It never calls `WriteAsync` or `EncodeValue`. A guard test reflects over `TransportDifferentialTests` and fails if any method body references `WriteAsync` (scan its source file).\n- [ ] **Browse parity:** `@tags` plus every program listing through both enumerators decode to identical `AbCipDiscoveredTag` sets (order-insensitive). Every `TemplateInstanceId` seen decodes to an identical `AbCipUdtShape` through both template readers.\n- [ ] **Read parity:** for each selected tag, under both transports:\n - Run `InitializeAsync` + `ReadAsync`.\n - Assert `GetStatus()` is equal, **except** that native `TypeMismatch` where libplctag says `Ok` is reported as a distinct *expected divergence* (spec \u00a75), listed in the output and not failed.\n - Assert the raw buffers are equal. To expose the raw bytes, add `internal ReadOnlyMemory<byte> RawBuffer` to both runtimes: libplctag's via `_tag.GetBuffer()`, native's via its stored data. Add `InternalsVisibleTo` for the integration test assembly in the AbCip csproj.\n - Assert the decoded values are equal.\n- [ ] **Connection discipline (review finding 9):**\n - 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.\n - 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.\n - With `AB_DIFF_COUNT_OPENS=1`, the native side asserts it made exactly **one** Forward Open over the whole run, using `CipConnection.ForwardOpenCount` from Task 9 (reset it at the start of the run).\n- [ ] Output: a markdown table (tag, type, libplctag status, native status, equal?) written to `TestContext.Current.TestOutputHelper`.\n- [ ] Results are appended under the gate-results doc's `## Differential vs ab_server (T18)` heading only.\n- [ ] Run against `ab_server` (`controllogix` profile) with `AB_DIFF_ENDPOINT=10.100.0.35:44818` \u21d2 zero unexpected divergences. Append the results to the gate-results doc.\n- [ ] **Ordering:** blocked by Task 17, so any transport fixes found by the ab_server suites land before the differential run.\n\n**Verify:** `AB_DIFF_ENDPOINT=10.100.0.35:44818 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --filter \"FullyQualifiedName~Differential\"` \u2192 pass with 0 unexpected divergences; without the env var \u2192 all skipped.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 18.\n\n```json:metadata\n{\"files\": [\"ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialTarget.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs\"], \"verifyCommand\": \"AB_DIFF_ENDPOINT=10.100.0.35:44818 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --filter \\\"FullyQualifiedName~Differential\\\"\", \"modelTier\": \"frontier\", \"model\": \"opus\", \"acceptanceCriteriaCount\": 9}\n```",
|
|
"blockedBy": [
|
|
17
|
|
]
|
|
},
|
|
{
|
|
"id": 19,
|
|
"subject": "Task 19: AdminUI `Transport` dropdown (AbCip + AbLegacy) + d",
|
|
"status": "completed",
|
|
"description": "**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.\n\n**Files:**\n- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor` (`FormModel.Transport`, `ToOptions`/`FromOptions`, an `InputSelect` in the advanced section)\n- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor` (same)\n- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs` (add Transport round-trip cases)\n- Create or modify: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs` (exists \u21d2 add cases; absent \u21d2 create, modelled on the AbLegacy file)\n\n**Acceptance Criteria:**\n- [ ] The dropdown has options \"libplctag (default)\" \u21d2 `Libplctag` and \"Native (managed)\" \u21d2 `Native`, bound with `@bind-Value:after=\"EmitAsync\"` the way the other fields are.\n- [ ] The serialized JSON contains `\"transport\":\"Native\"` (a string, not a number). It goes through the existing `_jsonOpts` with `JsonStringEnumConverter` (`AbCipDriverForm.razor:108-113`); verify the AbLegacy form has the same converter and add it if missing.\n- [ ] Loading a config without `transport` shows \"libplctag (default)\" and re-emits **without** a `transport` key, or with `\"Libplctag\"`; either is fine, but pick one and test it.\n- [ ] **Live `/run` on docker-dev** (login is disabled; drive it yourself with the Chrome tools against `http://localhost:9200`):\n 1. Bring up the AbCip `controllogix` fixture on `10.100.0.35`.\n 2. In `/raw`, create or edit an AbCip driver with a device `ab://10.100.0.35:44818/1,0`, set Transport = Native, and add the raw tag `TestDINT`.\n 3. Deploy.\n 4. Read the UNS/raw node with the Client CLI (`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n \"ns=<raw ns>;s=<RawPath>\"`) and get `Good`.\n 5. Confirm from the central node's logs that the native runtime served it. Add one `LogDebug(\"AbCip transport={Transport}\")` at driver init if no log line distinguishes the two.\n 6. Flip back to libplctag, redeploy, and read `Good` again.\n 7. Record the output under the gate-results doc's `## AdminUI /run (T19)` heading only.\n- [ ] **Inverted-gate check** (memory: \"an unchanged observable proves nothing\"): with Transport = Native, point the device at a port where no `ab_server` is listening. The read must go **Bad** and the log must show the *native* error text. That proves the switch changes behaviour and isn't inert.\n\n**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`.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 19.\n\n```json:metadata\n{\"files\": [\"src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor\", \"src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor\", \"tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs\", \"tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs\"], \"verifyCommand\": \"\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 5}\n```",
|
|
"blockedBy": [
|
|
17
|
|
]
|
|
},
|
|
{
|
|
"id": 20,
|
|
"subject": "Task 20: Documentation",
|
|
"status": "completed",
|
|
"description": "**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.\n\n**Files:**\n- Modify: `docs/drivers/AbCip.md` (sections: \"Transport (`Transport` option)\", \"Native transport behaviour\" covering connection sharing, MSP packing, the type check and press-safety limits, and \"Known gaps\" covering simulator-only writes, PLC-5/Micro800/routed paths, and libplctag deletion blocked on a bench controller)\n- Modify: `docs/drivers/AbLegacy.md` (Transport section, PCCC simulator-only gap, PLC-5 status from Task 8, ST byte-order status from Task 14)\n- Modify: `docs/drivers/AbServer-Test-Fixture.md` and `docs/drivers/AbLegacy-Test-Fixture.md` (suites now run per transport; the capture recipe `scripts/ab-capture/capture.sh`)\n- 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)\n- Modify: `../scadaproj/.claude/skills/sister-project-history/otopcua.md` (append a dated entry), on scadaproj's `main` per the CLAUDE.md procedure\n\n**Acceptance Criteria:**\n- [ ] `docs/drivers/AbCip.md` states explicitly:\n - The native path reports `BadTypeMismatch` for a same-width mis-authored type that libplctag reads as Good garbage, with an example (DINT authored, REAL returned).\n - That is the fix working, and it is called out as client-visible.\n- [ ] Every config key added (`transport`) is documented with its values, its default and string serialization.\n- [ ] The scadaproj history entry is committed on `main` and pushed. The OtOpcUa index **row** in `../scadaproj/CLAUDE.md` is **not** touched; the summary does not change, since the driver set and wire contract are the same.\n- [ ] The scadaproj branch is restored afterwards.\n\n**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.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 20.\n\n```json:metadata\n{\"files\": [\"../scadaproj/.claude/skills/sister-project-history/otopcua.md\", \"CLAUDE.md\", \"docs/drivers/AbCip.md\", \"docs/drivers/AbLegacy-Test-Fixture.md\", \"docs/drivers/AbLegacy.md\", \"docs/drivers/AbServer-Test-Fixture.md\", \"scripts/ab-capture/capture.sh\"], \"verifyCommand\": \"grep -n \\\"Transport\\\" docs/drivers/AbCip.md docs/drivers/AbLegacy.md CLAUDE.md\", \"modelTier\": \"standard\", \"model\": \"sonnet\", \"acceptanceCriteriaCount\": 4}\n```",
|
|
"blockedBy": [
|
|
19
|
|
]
|
|
},
|
|
{
|
|
"id": 21,
|
|
"subject": "Task 21: Real-press read-only gate (CR CompactLogix)",
|
|
"status": "completed",
|
|
"description": "**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.\n\n> **USER-ORDERED GATE \u2014 NON-SKIPPABLE.** This task was requested by the user in the current conversation. It MUST NOT be closed by walking around it, by declaring it \"verified inline\", or by substituting a cheaper check. Close only after every item in `acceptanceCriteria` has been re-validated independently, with output captured.\n\n**Files:**\n- Modify: `docs/plans/2026-10-07-native-ab-transport-gate-results.md` (append the press results)\n- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs`\n\n**Acceptance Criteria:**\n- [ ] **Preconditions (human):** the VPN to CR is up, the user has OK'd touching the press at this time, and the target is **one** press. The default is Z-35162 at `10.209.70.160:44818`, path `1,0`; any other press must be named by the user.\n- [ ] **Run the differential:**\n - `AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_DIFF_FAMILY=CompactLogix AB_DIFF_COUNT_OPENS=1 dotnet test \u2026--filter \"FullyQualifiedName~Differential\"` passes.\n - Browse parity holds: the same discovered tag set (the 2026-10-07 run saw ~23,412 variables across 80 programs) and identical UDT shapes.\n - Read parity holds over `AB_DIFF_MAX_TAGS=200`, with zero unexpected divergences. Every `TypeMismatch` expected-divergence is listed by tag name.\n - 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.\n- [ ] **Latency comparison** (`PollLatencyComparisonTests`, env-gated by the same variables):\n - 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.\n - 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.\n - Record the CIP round-trips per pass (native: `CipConnection.RequestsSent` from Task 9, delta per pass; libplctag: report n/a) and the p50/p95 wall time per pass.\n - Pass criterion: native p95 \u2264 libplctag p95 \u00d7 1.10.\n- [ ] **No writes:**\n - Evidence: the Task 18 source guard passes.\n - `grep -c WriteAsync` over both differential test files returns 0.\n- [ ] **Recorded results:** under the `## Real press (T21)` and `## Latency (T21)` headings, the results doc carries the raw `dotnet test` output, the divergence table, the latency table, the Forward Open count, the date/time (UTC) of the run and the press ID.\n- [ ] **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.\n\n**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\"` \u2192 passed, 0 unexpected divergences, `ForwardOpenCount=1`, and native p95 \u2264 1.10 \u00d7 libplctag p95.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 21.\n\n```json:metadata\n{\"files\": [\"docs/plans/2026-10-07-native-ab-transport-gate-results.md\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs\"], \"verifyCommand\": \"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\\\"\", \"modelTier\": \"frontier\", \"model\": \"opus\", \"userGate\": true, \"tags\": [\"user-gate\"], \"gateScope\": \"task\", \"failurePolicy\": \"keep-open-and-fix\", \"requireEvidenceTokens\": [[\"libplctag\", \"baseline\"], [\"native\"], [\"ForwardOpenCount=1\", \"ForwardOpenCount: 1\"]], \"acceptanceCriteriaCount\": 6}\n```",
|
|
"blockedBy": [
|
|
18
|
|
]
|
|
},
|
|
{
|
|
"id": 22,
|
|
"subject": "Task 22: Flip the defaults to Native + release note",
|
|
"status": "completed",
|
|
"description": "**Goal:** Make `Native` the default transport for both drivers (spec \u00a78 P5), keeping `Libplctag` selectable, and write the client-visible release note.\n\n**Files:**\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs` (default `AbCipTransport.Native`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs` (`fallback: AbCipTransport.Native`)\n- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs` (same)\n- Modify: both AdminUI forms (relabel the options as \"Native (default)\" / \"libplctag (fallback)\")\n- 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 \u21d2 Native expectation)\n- Modify: the CLIs' `--transport` default (`native`)\n- Modify: `docs/drivers/AbCip.md`, `docs/drivers/AbLegacy.md` (the default, and a \"Release note\" subsection)\n\n**Acceptance Criteria:**\n- [ ] Blocked by Task 21 passing **and** Task 17 green for all AbLegacy profiles.\n- [ ] A config with no `transport` key runs Native in both drivers; prove it with the switch tests.\n- [ ] Existing unit tests that construct `AbCipDriver` with **no** injected factory and expect libplctag behaviour are updated to set `Transport = Libplctag` explicitly. Find them with `grep -rn \"new AbCipDriver(\" tests | grep -v tagFactory`.\n- [ ] The release note says:\n - The native transport is the default.\n - It reports `BadTypeMismatch` for mis-authored same-width types.\n - Set `\"transport\": \"Libplctag\"` to fall back.\n - AbCip writes and all of AbLegacy are simulator-proven only.\n- [ ] The full `dotnet test ZB.MOM.WW.OtOpcUa.slnx` passes, except suites that need fixtures that are not up; those are listed as skipped.\n\n**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` \u2192 all pass.\n\nSteps + code: docs/plans/2026-10-07-native-ab-transport.md \u00a7 Task 22.\n\n```json:metadata\n{\"files\": [\"docs/drivers/AbCip.md\", \"docs/drivers/AbLegacy.md\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs\", \"src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs\", \"tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs\"], \"verifyCommand\": \"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\", \"modelTier\": \"mechanical\", \"model\": \"haiku\", \"acceptanceCriteriaCount\": 5}\n```",
|
|
"blockedBy": [
|
|
21,
|
|
17,
|
|
19,
|
|
20
|
|
]
|
|
}
|
|
],
|
|
"lastUpdated": "2026-10-07T19:16:30.130246+00:00"
|
|
} |