2761 lines
183 KiB
Markdown
2761 lines
183 KiB
Markdown
# Native AB Transport (replace libplctag) Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Put a fully managed EtherNet/IP + CIP + PCCC client under the AbCip and AbLegacy drivers, selectable per driver against the existing libplctag path, and prove it with the integration suites, a differential harness, and a read-only real-press gate.
|
||
|
||
**Architecture:** Add a new pure-protocol project, `ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport` (encapsulation, CIP paths and services, connection manager, request scheduler with Multiple Service Packet coalescing, PCCC), referenced by both drivers. Each driver gains `Native*` adapters behind its existing runtime/enumerator/template-reader interfaces, plus a `Transport` option (`Libplctag` | `Native`, a string enum) that picks the factory set. libplctag is confined to the `Libplctag*.cs` adapters and translates its `Status` into a driver-owned `AbStatus`.
|
||
|
||
**Tech Stack:** .NET 10, C#, xUnit v3 + Shouldly, `System.Net.Sockets`, `System.Buffers.Binary.BinaryPrimitives`, the `ab_server` Docker fixtures on `10.100.0.35`, Blazor (AdminUI form).
|
||
|
||
**Spec:** `docs/plans/2026-10-07-native-ab-transport-design.md` (commit `45b6062d`). Executors read both documents. Where this plan and the spec disagree, the deviations listed under Global Constraints win.
|
||
|
||
## Global Constraints
|
||
|
||
- **Wire format:** everything is little-endian. Use `BinaryPrimitives` and the `CipWriter`/`CipReader` helpers from Task 1. No `BitConverter`, because it is endianness-dependent.
|
||
- **The decoders do not change.** `CipSymbolObjectDecoder` and `CipTemplateObjectDecoder` stay byte-for-byte as they are. The native enumerator and template reader must emit the exact buffer layouts those decoders document (spec D8):
|
||
- The symbol listing entry is `u32 instanceId, u16 symbolType, u16 elementLength, u32×3 dims, u16 nameLen, name` with **no padding**.
|
||
- The template buffer is a 14-byte header `u16 id, u32 defSizeWords, u32 instanceSize, u16 memberCount, u16 handle`, followed by the raw Read Template bytes.
|
||
- **`AbStatus` is the only status vocabulary above the adapters.** After Task 2/3 no file outside `Libplctag*.cs` may contain `using libplctag`. Task 11 enforces this with a source-scan guard.
|
||
- **`IAbCipTagRuntime.GetStatus()` / `IAbLegacyTagRuntime.GetStatus()` return `(int)AbStatus`** under both transports. A zero value still means success, so every existing `status != 0` check stays correct.
|
||
- **The `Transport` option is serialized as a string** (`"Native"` / `"Libplctag"`). That means `JsonStringEnumConverter` in the AdminUI form, and the existing string DTO + `ParseEnum` in the factory extensions. An absent value means `Libplctag` until Task 22 flips it.
|
||
- **Press safety, for every native code path:**
|
||
- At most **one Forward Open in flight per device**, and a **minimum 1 s** between Forward Open attempts per device.
|
||
- Exactly one CIP connection per `(host, port, cipPath, plcAttribute)`.
|
||
- Forward Close on the last lease release.
|
||
- **Real presses are touched only in Task 21, read-only, one press at a time.**
|
||
- **One request in flight per connection.** `EncapsulationClient` serializes requests and `RequestScheduler` has `MaxInFlight = 1` hard-wired. *Deviation from spec §3.1, which allowed a configurable `MaxInFlight`; per YAGNI there is no knob in v1.*
|
||
- **One `RequestScheduler` per device connection, never per tag.** It lives in the `DeviceSessionPool` entry beside the `CipConnection` and is exposed as `ConnectionLease.Scheduler`. Per-tag schedulers would make MSP coalescing impossible and race the connected sequence counter. That is review finding B1.
|
||
- **The connected sequence count is assigned inside `EncapsulationClient`'s single-flight gate**, never by a caller (B1).
|
||
- **Caller cancellation never faults the shared connection.** A poll subscription's CTS is cancelled on every OPC UA unsubscribe (`PollGroupEngine.cs:160`). Faulting the socket on that would drop every tag on the press and force a fresh Forward Open. Instead:
|
||
- Frames are written uncancellably (they are small and bounded by the socket).
|
||
- Cancellation during the reply wait abandons only the waiter. The stale reply is skipped by context or sequence on the next exchange.
|
||
- Only a **timeout**, an I/O error or a malformed frame faults the connection. That is review finding B2.
|
||
- **Routing is explicit:** `ConnectionOptions.Routing ∈ { Connected, UnconnectedSend, Direct }`.
|
||
- `Connected` = Forward Open + SendUnitData.
|
||
- `UnconnectedSend` = SendRRData wrapping 0x52.
|
||
- `Direct` = SendRRData carrying the CIP request bare, as libplctag does for SLC/MicroLogix/PLC-5 Execute PCCC even with a `1,0` path.
|
||
- The **captures decide** each family's mode (B3).
|
||
- **Model tiers:** `mechanical` ⇒ **haiku**, `standard` ⇒ **sonnet**, `frontier` ⇒ **opus**. Each task's metadata carries `model`.
|
||
- **Parallel execution:** tasks in the same wave touch disjoint files and may run concurrently, **each in its own git worktree** off `feat/native-ab-transport`. They merge back serially in task-id order. Only Task 1 edits `ZB.MOM.WW.OtOpcUa.slnx`. No task edits `Directory.Packages.props`.
|
||
- **Build gate for every task:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` with 0 warnings (`TreatWarningsAsErrors` is on for driver projects), and the task's test command green.
|
||
- **Naming:** test classes follow `<Subject>Tests`, test methods use sentence-style names (`A_refused_forward_open_is_not_retried_inside_the_minimum_interval`), and test projects use xUnit v3 + Shouldly, matching `ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj`.
|
||
|
||
**User decisions (already made):**
|
||
- "all of the above". All four motivations apply (native-binary pain, correctness/control, performance, policy).
|
||
- "1, side-by-side behind a switch". There is a per-driver `Transport` switch and libplctag stays as the fallback.
|
||
- "use recommended choices for all choices". The proof standard flips per evidence tier: AbCip reads, browse and templates must pass the real presses read-only; AbCip writes and all of AbLegacy are simulator-proven and recorded as a known gap. libplctag is deleted only after a bench controller exists. The stack lives in a new shared `Driver.Ab.Transport` project, with one connection per device, transparent MSP coalescing, a driver-owned `AbStatus`, reuse of the libplctag-shaped decoder buffers, and differential testing as the primary evidence.
|
||
- "generate implementation plan to use subagents with appropriate models in parallel where safe. use fable subagent to review plans when done".
|
||
|
||
**Out of plan (blocked, not a task):** spec Phase 6, deleting libplctag. It is blocked until a bench CompactLogix or Micro800 (for writes) and an SLC or MicroLogix (for PCCC) are reachable. Record it in `docs/drivers/AbCip.md` "Known gaps" in Task 20.
|
||
|
||
---
|
||
|
||
## Execution waves
|
||
|
||
| Wave | Tasks (parallel within a wave) | Notes |
|
||
|---|---|---|
|
||
| 0 | T1 | Creates the project, the slnx entries, the shared contracts and the empty gate-results doc. |
|
||
| 1 | T2, T3, T4, T6 | T2/T3 are driver seams (disjoint projects); T4 captures golden frames from the docker host; T6 is the path encoders. |
|
||
| 2 | T5, T7, T8, T11 | T5 needs T4 (capture parity); T7 needs T4+T6; T8 needs T4; T11 needs T2+T3. All disjoint folders. |
|
||
| 3 | T10 | The scheduler (needs T7). |
|
||
| 4 | T9 | Connection + pool (needs T5, T7, T10): the pool entry owns the scheduler. |
|
||
| 5 | T12, T13, T14 | T12/T13 are disjoint AbCip files (need T2, T9); T14 is AbLegacy (needs T3, T8, T9). |
|
||
| 6 | T15, T16 | AbCip switch (needs T12, T13) and AbLegacy switch (needs T14), in disjoint projects. |
|
||
| 7 | T17 | Integration suites on both transports (needs T15, T16). It appends to the gate-results doc. |
|
||
| 8 | T18, T19 | Differential harness (needs T17); AdminUI + `/run` (needs T17, since both append to the gate-results doc, so they are serialized behind T17; T18 and T19 append to **different headed sections** created by T1, which merge cleanly). |
|
||
| 9 | T20 | Docs (needs T19's evidence and the T8/T14 verification status). |
|
||
| 10 | T21 → T22 | Real-press gate (needs T18), then the default flip (needs T21, T17, T19, T20). Serial. |
|
||
|
||
Dependency list (authoritative; mirrors `.tasks.json` `blockedBy`):
|
||
T2,T3,T4,T6 ← T1 · T5 ← T1,T4 · T7 ← T4,T6 · T8 ← T4 · T11 ← T2,T3 · T10 ← T7 · T9 ← T5,T7,T10 ·
|
||
T12,T13 ← T2,T9 · T14 ← T3,T8,T9 · T15 ← T12,T13 · T16 ← T14 · T17 ← T15,T16 ·
|
||
T18 ← T17 · T19 ← T17 · T20 ← T19 · T21 ← T18 · T22 ← T21,T17,T19,T20
|
||
|
||
**Model per task:**
|
||
|
||
| Tier | Model | Tasks |
|
||
|---|---|---|
|
||
| frontier | opus | T4, T9, T10, T12, T18, T21 |
|
||
| standard | sonnet | T1, T2, T3, T5, T6, T7, T8, T13, T14, T15, T16, T17, T19, T20 |
|
||
| mechanical | haiku | T11, T22 |
|
||
|
||
---
|
||
|
||
### Task 1: Ab.Transport project skeleton + shared contracts
|
||
|
||
**Goal:** Create `ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport` and its test project, wired into the solution. It carries the shared vocabulary that every later task codes against: `AbStatus`, `AbTransportException`, `AbStatusCodes`, `CipWriter`/`CipReader`, `CipReply`, and `ICipChannel`.
|
||
|
||
**Files:**
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatus.cs`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbTransportException.cs`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/AbStatusCodes.cs`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipWriter.cs`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Wire/CipReader.cs`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipReply.cs`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ICipChannel.cs`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.csproj`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/AbStatusCodesTests.cs`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Wire/CipReplyTests.cs`
|
||
- Create: `docs/plans/2026-10-07-native-ab-transport-gate-results.md` (skeleton with five empty headed sections, `## ab_server suites (T17)`, `## Differential vs ab_server (T18)`, `## AdminUI /run (T19)`, `## Real press (T21)` and `## Latency (T21)`, so later tasks append to separate sections and merge cleanly)
|
||
- Modify: `ZB.MOM.WW.OtOpcUa.slnx` (add both projects next to the AbCip entries, lines 41-44 and 112-115)
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj` and `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj` (add a `ProjectReference` to Ab.Transport)
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] `dotnet build ZB.MOM.WW.OtOpcUa.slnx` succeeds with 0 warnings and both new projects appear in the slnx.
|
||
- [ ] `AbStatus.Ok == 0`, and every error member is negative.
|
||
- [ ] `AbStatusCodes.FromCip` maps CIP general status as follows. It is aligned with the existing `AbCipStatusMapper.MapCipGeneralStatus`, so that existing mapping does not regress (review finding 13).
|
||
- `0x00`/`0x0B` → `Ok`
|
||
- `0x04`/`0x05`/`0x16` → `NotFound`
|
||
- `0x06` → `Partial`
|
||
- `0x08` → `Unsupported`
|
||
- `0x0A` → `OutOfBounds`
|
||
- `0x0C`/`0x10` → `DeviceStateConflict`
|
||
- `0x0E`/`0x0F` → `NotAllowed`
|
||
- `0x13` → `TooSmall`
|
||
- `0x15` → `TooLarge`
|
||
- `0xFF` + `0x2105` → `OutOfBounds`
|
||
- `0xFF` + `0x2107` → `TypeMismatch`
|
||
- everything else, including `0x1E`, → `BadReply`
|
||
`0x1E` (embedded service error) is only meaningful on an MSP envelope, and the scheduler (T10) handles it before calling `FromCip`.
|
||
- [ ] `AbStatusCodes.FromPccc` maps STS `0x00→Ok`, `0x10→Unsupported`, `0x20→NotFound`, `0x30→NotAllowed`, `0x40/0x50→DeviceStateConflict`, and `0xF0` maps via EXT STS (`0x06/0x07→NotFound` address problems, else `BadReply`).
|
||
- [ ] `CipReply.Parse` round-trips a reply with 0, 1 and 2 extended-status words.
|
||
- [ ] The driver projects reference Ab.Transport, and Ab.Transport references no OtOpcUa project.
|
||
|
||
**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests` → all pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Create the projects.** The library csproj mirrors the AbCip csproj property group (net10.0, nullable, `TreatWarningsAsErrors`, `GenerateDocumentationFile`, `NoWarn CS1591`). Its only package reference is `Microsoft.Extensions.Logging.Abstractions`. Check it is already pinned in `Directory.Packages.props` with `grep -n Logging.Abstractions Directory.Packages.props`. If it is absent, do **not** add it; the logger is optional and can be dropped from this project.
|
||
|
||
```xml
|
||
<Project Sdk="Microsoft.NET.Sdk">
|
||
<PropertyGroup>
|
||
<TargetFramework>net10.0</TargetFramework>
|
||
<Nullable>enable</Nullable>
|
||
<ImplicitUsings>enable</ImplicitUsings>
|
||
<LangVersion>latest</LangVersion>
|
||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport</RootNamespace>
|
||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport</AssemblyName>
|
||
</PropertyGroup>
|
||
<ItemGroup>
|
||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
|
||
</ItemGroup>
|
||
<ItemGroup>
|
||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests"/>
|
||
</ItemGroup>
|
||
</Project>
|
||
```
|
||
|
||
The test csproj is a copy of `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj` with the RootNamespace and ProjectReference retargeted. Drop the `OTOPCUA0001` NoWarn.
|
||
|
||
- [ ] **Step 2: Write the failing tests.**
|
||
|
||
```csharp
|
||
// tests/.../AbStatusCodesTests.cs
|
||
using Shouldly;
|
||
using Xunit;
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests;
|
||
|
||
public sealed class AbStatusCodesTests
|
||
{
|
||
[Theory]
|
||
[InlineData((byte)0x00, AbStatus.Ok)]
|
||
[InlineData((byte)0x04, AbStatus.NotFound)]
|
||
[InlineData((byte)0x05, AbStatus.NotFound)]
|
||
[InlineData((byte)0x16, AbStatus.NotFound)]
|
||
[InlineData((byte)0x06, AbStatus.Partial)]
|
||
[InlineData((byte)0x08, AbStatus.Unsupported)]
|
||
[InlineData((byte)0x0F, AbStatus.NotAllowed)]
|
||
[InlineData((byte)0x10, AbStatus.DeviceStateConflict)]
|
||
[InlineData((byte)0x13, AbStatus.TooSmall)]
|
||
[InlineData((byte)0x15, AbStatus.TooLarge)]
|
||
[InlineData((byte)0x0B, AbStatus.Ok)]
|
||
[InlineData((byte)0x0A, AbStatus.OutOfBounds)]
|
||
[InlineData((byte)0x0C, AbStatus.DeviceStateConflict)]
|
||
[InlineData((byte)0x0E, AbStatus.NotAllowed)]
|
||
[InlineData((byte)0x1E, AbStatus.BadReply)]
|
||
[InlineData((byte)0x01, AbStatus.BadReply)]
|
||
public void Cip_general_status_maps(byte general, AbStatus expected) =>
|
||
AbStatusCodes.FromCip(general, ReadOnlySpan<ushort>.Empty).ShouldBe(expected);
|
||
|
||
[Theory]
|
||
[InlineData((ushort)0x2105, AbStatus.OutOfBounds)]
|
||
[InlineData((ushort)0x2107, AbStatus.TypeMismatch)]
|
||
[InlineData((ushort)0x9999, AbStatus.BadReply)]
|
||
public void General_status_FF_maps_by_extended_status(ushort ext, AbStatus expected) =>
|
||
AbStatusCodes.FromCip(0xFF, [ext]).ShouldBe(expected);
|
||
|
||
[Theory]
|
||
[InlineData((byte)0x00, null, AbStatus.Ok)]
|
||
[InlineData((byte)0x10, null, AbStatus.Unsupported)]
|
||
[InlineData((byte)0x20, null, AbStatus.NotFound)]
|
||
[InlineData((byte)0x30, null, AbStatus.NotAllowed)]
|
||
[InlineData((byte)0x40, null, AbStatus.DeviceStateConflict)]
|
||
[InlineData((byte)0xF0, (byte)0x06, AbStatus.NotFound)]
|
||
[InlineData((byte)0xF0, (byte)0x07, AbStatus.NotFound)]
|
||
[InlineData((byte)0xF0, (byte)0x0A, AbStatus.BadReply)]
|
||
public void Pccc_status_maps(byte sts, byte? ext, AbStatus expected) =>
|
||
AbStatusCodes.FromPccc(sts, ext).ShouldBe(expected);
|
||
|
||
[Fact]
|
||
public void Ok_is_zero_and_every_error_is_negative()
|
||
{
|
||
((int)AbStatus.Ok).ShouldBe(0);
|
||
foreach (var s in Enum.GetValues<AbStatus>().Where(s => s is not AbStatus.Ok and not AbStatus.Pending))
|
||
((int)s).ShouldBeLessThan(0);
|
||
}
|
||
}
|
||
```
|
||
|
||
```csharp
|
||
// tests/.../Wire/CipReplyTests.cs
|
||
using Shouldly;
|
||
using Xunit;
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Wire;
|
||
|
||
public sealed class CipReplyTests
|
||
{
|
||
[Fact]
|
||
public void Parses_success_reply_with_data()
|
||
{
|
||
// 0xCC = reply to Read Tag 0x4C; reserved; status 0; ext size 0; data C4 00 2A 00 00 00
|
||
var r = CipReply.Parse(new byte[] { 0xCC, 0x00, 0x00, 0x00, 0xC4, 0x00, 0x2A, 0x00, 0x00, 0x00 });
|
||
r.Service.ShouldBe((byte)0x4C);
|
||
r.GeneralStatus.ShouldBe((byte)0);
|
||
r.ExtendedStatus.Length.ShouldBe(0);
|
||
r.Data.ToArray().ShouldBe(new byte[] { 0xC4, 0x00, 0x2A, 0x00, 0x00, 0x00 });
|
||
}
|
||
|
||
[Fact]
|
||
public void Parses_error_reply_with_two_extended_words()
|
||
{
|
||
var r = CipReply.Parse(new byte[] { 0xCC, 0x00, 0xFF, 0x02, 0x05, 0x21, 0x00, 0x00 });
|
||
r.GeneralStatus.ShouldBe((byte)0xFF);
|
||
r.ExtendedStatus.ToArray().ShouldBe(new ushort[] { 0x2105, 0x0000 });
|
||
r.Data.Length.ShouldBe(0);
|
||
r.Status.ShouldBe(AbStatus.OutOfBounds);
|
||
}
|
||
|
||
[Fact]
|
||
public void Truncated_reply_throws_BadReply()
|
||
{
|
||
var ex = Should.Throw<AbTransportException>(() => CipReply.Parse(new byte[] { 0xCC, 0x00 }));
|
||
ex.Status.ShouldBe(AbStatus.BadReply);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run the tests and confirm they fail.** Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests`. Expected: build errors (types are not defined yet).
|
||
|
||
- [ ] **Step 4: Implement.**
|
||
|
||
```csharp
|
||
// AbStatus.cs
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||
|
||
/// <summary>Transport-neutral status shared by the libplctag and native AB transports.
|
||
/// Zero is success; every error is negative so existing <c>status != 0</c> checks stay correct.</summary>
|
||
public enum AbStatus
|
||
{
|
||
Ok = 0,
|
||
Pending = 1,
|
||
Timeout = -1,
|
||
NotFound = -2,
|
||
NotAllowed = -3,
|
||
OutOfBounds = -4,
|
||
TooLarge = -5,
|
||
TooSmall = -6,
|
||
Unsupported = -7,
|
||
BadConnection = -8,
|
||
BadReply = -9,
|
||
Partial = -10,
|
||
Abort = -11,
|
||
TypeMismatch = -12,
|
||
DeviceStateConflict = -13,
|
||
}
|
||
```
|
||
|
||
```csharp
|
||
// AbTransportException.cs
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||
|
||
/// <summary>Thrown by both AB transports. <see cref="Status"/> drives OPC UA status mapping and the
|
||
/// driver's "controller answered, don't throttle the device" classification (05/STAB-8).</summary>
|
||
public sealed class AbTransportException(AbStatus status, string message, Exception? inner = null)
|
||
: Exception(message, inner)
|
||
{
|
||
public AbStatus Status { get; } = status;
|
||
}
|
||
```
|
||
|
||
```csharp
|
||
// AbStatusCodes.cs
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||
|
||
public static class AbStatusCodes
|
||
{
|
||
public static AbStatus FromCip(byte general, ReadOnlySpan<ushort> extended) => general switch
|
||
{
|
||
0x00 or 0x0B => AbStatus.Ok,
|
||
0x04 or 0x05 or 0x16 => AbStatus.NotFound,
|
||
0x06 => AbStatus.Partial,
|
||
0x08 => AbStatus.Unsupported,
|
||
0x0A => AbStatus.OutOfBounds,
|
||
0x0C or 0x10 => AbStatus.DeviceStateConflict,
|
||
0x0E or 0x0F => AbStatus.NotAllowed,
|
||
0x13 => AbStatus.TooSmall,
|
||
0x15 => AbStatus.TooLarge,
|
||
0xFF when extended.Length > 0 && extended[0] == 0x2105 => AbStatus.OutOfBounds,
|
||
0xFF when extended.Length > 0 && extended[0] == 0x2107 => AbStatus.TypeMismatch,
|
||
_ => AbStatus.BadReply,
|
||
};
|
||
|
||
public static AbStatus FromPccc(byte sts, byte? extSts) => sts switch
|
||
{
|
||
0x00 => AbStatus.Ok,
|
||
0x10 => AbStatus.Unsupported,
|
||
0x20 => AbStatus.NotFound,
|
||
0x30 => AbStatus.NotAllowed,
|
||
0x40 or 0x50 => AbStatus.DeviceStateConflict,
|
||
0xF0 when extSts is 0x06 or 0x07 => AbStatus.NotFound,
|
||
_ => AbStatus.BadReply,
|
||
};
|
||
}
|
||
```
|
||
|
||
```csharp
|
||
// Wire/CipWriter.cs
|
||
using System.Buffers.Binary;
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||
|
||
/// <summary>Growable little-endian writer for CIP / EIP frames.</summary>
|
||
public sealed class CipWriter
|
||
{
|
||
private byte[] _buf = new byte[256];
|
||
public int Length { get; private set; }
|
||
|
||
private Span<byte> Take(int n)
|
||
{
|
||
if (Length + n > _buf.Length) Array.Resize(ref _buf, Math.Max(_buf.Length * 2, Length + n));
|
||
var s = _buf.AsSpan(Length, n);
|
||
Length += n;
|
||
return s;
|
||
}
|
||
|
||
public CipWriter U8(byte v) { Take(1)[0] = v; return this; }
|
||
public CipWriter U16(ushort v) { BinaryPrimitives.WriteUInt16LittleEndian(Take(2), v); return this; }
|
||
public CipWriter U32(uint v) { BinaryPrimitives.WriteUInt32LittleEndian(Take(4), v); return this; }
|
||
public CipWriter U64(ulong v) { BinaryPrimitives.WriteUInt64LittleEndian(Take(8), v); return this; }
|
||
public CipWriter Bytes(ReadOnlySpan<byte> v) { v.CopyTo(Take(v.Length)); return this; }
|
||
public CipWriter PadToEven() { if ((Length & 1) == 1) U8(0); return this; }
|
||
|
||
/// <summary>Overwrite a u16 already written at <paramref name="offset"/> (length back-patching).</summary>
|
||
public void PatchU16(int offset, ushort v) => BinaryPrimitives.WriteUInt16LittleEndian(_buf.AsSpan(offset, 2), v);
|
||
|
||
public byte[] ToArray() => _buf.AsSpan(0, Length).ToArray();
|
||
}
|
||
```
|
||
|
||
```csharp
|
||
// Wire/CipReader.cs
|
||
using System.Buffers.Binary;
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||
|
||
/// <summary>Bounds-checked little-endian reader. Any overrun throws AbTransportException(BadReply).</summary>
|
||
public ref struct CipReader(ReadOnlySpan<byte> data)
|
||
{
|
||
private readonly ReadOnlySpan<byte> _data = data;
|
||
public int Position { get; private set; }
|
||
public readonly int Remaining => _data.Length - Position;
|
||
|
||
private ReadOnlySpan<byte> Take(int n)
|
||
{
|
||
if (n < 0 || Position + n > _data.Length)
|
||
throw new AbTransportException(AbStatus.BadReply, $"Reply truncated: need {n} bytes at {Position}, have {Remaining}.");
|
||
var s = _data.Slice(Position, n);
|
||
Position += n;
|
||
return s;
|
||
}
|
||
|
||
public byte U8() => Take(1)[0];
|
||
public ushort U16() => BinaryPrimitives.ReadUInt16LittleEndian(Take(2));
|
||
public uint U32() => BinaryPrimitives.ReadUInt32LittleEndian(Take(4));
|
||
public ulong U64() => BinaryPrimitives.ReadUInt64LittleEndian(Take(8));
|
||
public ReadOnlySpan<byte> Bytes(int n) => Take(n);
|
||
public ReadOnlySpan<byte> Rest() => Take(Remaining);
|
||
}
|
||
```
|
||
|
||
```csharp
|
||
// Cip/CipReply.cs
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||
|
||
/// <summary>A parsed CIP message-router reply: service (request code, 0x80 bit stripped), general
|
||
/// status, extended status words, and the reply data.</summary>
|
||
public sealed record CipReply(byte Service, byte GeneralStatus, ushort[] ExtendedStatus, ReadOnlyMemory<byte> Data)
|
||
{
|
||
public AbStatus Status => AbStatusCodes.FromCip(GeneralStatus, ExtendedStatus);
|
||
|
||
public static CipReply Parse(ReadOnlyMemory<byte> raw)
|
||
{
|
||
var r = new CipReader(raw.Span);
|
||
var service = (byte)(r.U8() & 0x7F);
|
||
r.U8(); // reserved
|
||
var general = r.U8();
|
||
var extWords = r.U8();
|
||
var ext = new ushort[extWords];
|
||
for (var i = 0; i < extWords; i++) ext[i] = r.U16();
|
||
var data = raw[r.Position..];
|
||
return new CipReply(service, general, ext, data);
|
||
}
|
||
}
|
||
```
|
||
|
||
```csharp
|
||
// Cip/ICipChannel.cs
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||
|
||
/// <summary>A path to a CIP message router: connected (Forward Open) or unconnected (UCMM). Task 9
|
||
/// implements it; Task 10's scheduler and the native adapters consume it.</summary>
|
||
public interface ICipChannel
|
||
{
|
||
/// <summary>Largest CIP request (service byte through end of data) one call may carry.</summary>
|
||
int MaxRequestBytes { get; }
|
||
|
||
/// <summary>Send one CIP request and return the raw CIP reply bytes (reply-service byte first).
|
||
/// Throws <see cref="AbTransportException"/> on transport failure or timeout.</summary>
|
||
Task<ReadOnlyMemory<byte>> SendAsync(ReadOnlyMemory<byte> cipRequest, CancellationToken cancellationToken);
|
||
}
|
||
```
|
||
|
||
Add the two `<Project Path=...>` lines to the slnx. Add `<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj"/>` to both driver csprojs.
|
||
|
||
- [ ] **Step 5: Run the tests.** Run `dotnet build ZB.MOM.WW.OtOpcUa.slnx && dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests`. Expected: 0 warnings, all pass.
|
||
|
||
- [ ] **Step 6: Commit.**
|
||
|
||
```bash
|
||
git add docs/plans/2026-10-07-native-ab-transport-gate-results.md src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests ZB.MOM.WW.OtOpcUa.slnx src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj
|
||
git commit -m "feat(ab-transport): project skeleton with AbStatus, CIP reply parsing and the ICipChannel seam"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: AbCip seam — decouple the driver from libplctag types
|
||
|
||
**Goal:** Change every AbCip file other than the `Libplctag*.cs` adapters so it speaks `AbStatus` / `AbTransportException` instead of libplctag's `Status` / `LibPlcTagException`. Behaviour stays identical.
|
||
|
||
**Files:**
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs`, `LibplctagTagEnumerator.cs`, `LibplctagTemplateReader.cs` (wrap libplctag calls; translate)
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs`
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs` (replace `MapLibplctagStatus(Status)` / `TryGetStatus(LibPlcTagException)` with `MapStatus(AbStatus)`, `MapStatus(int)`, and `MapException`)
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs` (`IsReachableStatus(AbStatus)`, `IsReachableException(AbTransportException)`; construct runtimes via an `IAbCipTagFactory` parameter that defaults to `new LibplctagTagFactory()`)
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:1,1186-1191` (catch `AbTransportException`)
|
||
- Modify (tests): `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDriverWriteTests.cs`, `AbCipDriverProbeTests.cs`, `AbCipDriverReadTests.cs`, `AbCipStatusMapperTests.cs`, `AbCipDriverCodeReviewRegressionTests.cs` (line 326 uses `libplctag.Status.ErrorBadConnection`), `AbCipUnknownTagReadTests.cs`, `AbCipLoggingTests.cs` (line 91, same), `FakeAbCipTag.cs`. Run `grep -rln "libplctag" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --include=*.cs` and migrate **every** hit except `LibplctagStatusTranslationTests.cs` and the mapper parity oracle.
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs` (its `<see cref="AbCipStatusMapper.MapLibplctagStatus(int)"/>` must become `MapStatus(int)`. A dangling cref is CS1574, which `TreatWarningsAsErrors` turns into a build error.)
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs`
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] `grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/*.cs` lists only `LibplctagTagRuntime.cs`, `LibplctagTagEnumerator.cs`, `LibplctagTemplateReader.cs` and `LibplctagStatusTranslation.cs`.
|
||
- [ ] `LibplctagTagRuntime.GetStatus()` returns `(int)AbStatus`, and every libplctag call that can throw `LibPlcTagException` rethrows `AbTransportException` with the translated status and the original as `InnerException`.
|
||
- [ ] The translation table is the existing `MapLibplctagStatus` buckets re-expressed in `AbStatus`:
|
||
- `Ok→Ok`, `Pending→Pending`, `ErrorTimeout→Timeout`
|
||
- `ErrorNotFound/ErrorNoMatch/ErrorBadDevice→NotFound`
|
||
- `ErrorNotAllowed→NotAllowed`
|
||
- `ErrorOutOfBounds→OutOfBounds`, `ErrorTooLarge→TooLarge`, `ErrorTooSmall→TooSmall`
|
||
- `ErrorUnsupported/ErrorNotImplemented→Unsupported`
|
||
- `ErrorPartial→Partial`, `ErrorAbort→Abort`
|
||
- `ErrorBadReply→BadReply`
|
||
- every other member → `BadConnection`
|
||
- [ ] `AbCipStatusMapper.MapStatus(AbStatus)` produces exactly the OPC UA codes the old mapper produced for the corresponding libplctag members. `TypeMismatch` maps to `BadTypeMismatch` and `DeviceStateConflict` to `BadDeviceFailure`. `Partial` and `BadReply` must still map to `BadCommunicationError`, as `ErrorPartial` / `ErrorBadReply` did.
|
||
- [ ] The full existing `AbCip.Tests` suite is green, with the only changes being test fakes constructing `AbTransportException(AbStatus.X, ...)` instead of `LibPlcTagException("ErrorX")` and setting `Status = (int)AbStatus.X`.
|
||
|
||
**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests` → all pass, then `grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/*.cs` → exactly the four `Libplctag*` files.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Write the translation test (failing).**
|
||
|
||
```csharp
|
||
// tests/.../LibplctagStatusTranslationTests.cs
|
||
using libplctag;
|
||
using Shouldly;
|
||
using Xunit;
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||
|
||
public sealed class LibplctagStatusTranslationTests
|
||
{
|
||
[Theory]
|
||
[InlineData(Status.Ok, AbStatus.Ok)]
|
||
[InlineData(Status.Pending, AbStatus.Pending)]
|
||
[InlineData(Status.ErrorTimeout, AbStatus.Timeout)]
|
||
[InlineData(Status.ErrorNotFound, AbStatus.NotFound)]
|
||
[InlineData(Status.ErrorNoMatch, AbStatus.NotFound)]
|
||
[InlineData(Status.ErrorBadDevice, AbStatus.NotFound)]
|
||
[InlineData(Status.ErrorNotAllowed, AbStatus.NotAllowed)]
|
||
[InlineData(Status.ErrorOutOfBounds, AbStatus.OutOfBounds)]
|
||
[InlineData(Status.ErrorTooLarge, AbStatus.TooLarge)]
|
||
[InlineData(Status.ErrorTooSmall, AbStatus.TooSmall)]
|
||
[InlineData(Status.ErrorUnsupported, AbStatus.Unsupported)]
|
||
[InlineData(Status.ErrorNotImplemented, AbStatus.Unsupported)]
|
||
[InlineData(Status.ErrorPartial, AbStatus.Partial)]
|
||
[InlineData(Status.ErrorAbort, AbStatus.Abort)]
|
||
[InlineData(Status.ErrorBadReply, AbStatus.BadReply)]
|
||
[InlineData(Status.ErrorBadGateway, AbStatus.BadConnection)]
|
||
[InlineData(Status.ErrorWinsock, AbStatus.BadConnection)]
|
||
public void Translates(Status from, AbStatus to) => LibplctagStatusTranslation.ToAbStatus(from).ShouldBe(to);
|
||
|
||
[Fact]
|
||
public void Exception_message_is_recovered_as_the_enum_name()
|
||
{
|
||
LibplctagStatusTranslation.ToAbStatus(new LibPlcTagException("ErrorNotFound")).ShouldBe(AbStatus.NotFound);
|
||
LibplctagStatusTranslation.ToAbStatus(new LibPlcTagException("gibberish")).ShouldBe(AbStatus.BadConnection);
|
||
}
|
||
}
|
||
```
|
||
|
||
Add a matching parity test to `AbCipStatusMapperTests`: for every `Status` member, `MapStatus(ToAbStatus(s))` must equal the OPC UA code the **old** `MapLibplctagStatus(s)` returned. Copy the old switch into the test as an expected-value table **before deleting it from production**. That table is the regression oracle.
|
||
|
||
- [ ] **Step 2: Run and confirm it fails.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter LibplctagStatusTranslation` → compile error.
|
||
|
||
- [ ] **Step 3: Implement the translation and wrap the adapters.**
|
||
|
||
```csharp
|
||
// LibplctagStatusTranslation.cs
|
||
using libplctag;
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||
|
||
/// <summary>The ONLY place libplctag's <see cref="Status"/> meets <see cref="AbStatus"/>.</summary>
|
||
internal static class LibplctagStatusTranslation
|
||
{
|
||
public static AbStatus ToAbStatus(Status s) => s switch
|
||
{
|
||
Status.Ok => AbStatus.Ok,
|
||
Status.Pending => AbStatus.Pending,
|
||
Status.ErrorTimeout => AbStatus.Timeout,
|
||
Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.NotFound,
|
||
Status.ErrorNotAllowed => AbStatus.NotAllowed,
|
||
Status.ErrorOutOfBounds => AbStatus.OutOfBounds,
|
||
Status.ErrorTooLarge => AbStatus.TooLarge,
|
||
Status.ErrorTooSmall => AbStatus.TooSmall,
|
||
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbStatus.Unsupported,
|
||
Status.ErrorPartial => AbStatus.Partial,
|
||
Status.ErrorAbort => AbStatus.Abort,
|
||
Status.ErrorBadReply => AbStatus.BadReply,
|
||
_ => AbStatus.BadConnection,
|
||
};
|
||
|
||
/// <summary>libplctag builds the exception from <c>Status.ToString()</c>, so the message is the enum name.</summary>
|
||
public static AbStatus ToAbStatus(LibPlcTagException ex) =>
|
||
Enum.TryParse<Status>((ex.Message ?? "").Trim(), ignoreCase: true, out var s) && Enum.IsDefined(s)
|
||
? ToAbStatus(s)
|
||
: AbStatus.BadConnection;
|
||
|
||
public static async Task Wrap(Func<Task> op)
|
||
{
|
||
try { await op().ConfigureAwait(false); }
|
||
catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
|
||
}
|
||
}
|
||
```
|
||
|
||
In `LibplctagTagRuntime`: `InitializeAsync/ReadAsync/WriteAsync => LibplctagStatusTranslation.Wrap(() => _tag.XAsync(ct))`, and `GetStatus() => (int)LibplctagStatusTranslation.ToAbStatus(_tag.GetStatus())`. Do the same in the enumerator's `ReadListingAsync` and in `LibplctagTemplateReader.ReadAsync`.
|
||
|
||
- [ ] **Step 4: Rewrite the mapper and probe and the driver catch.**
|
||
|
||
`AbCipStatusMapper` (remove `using libplctag`):
|
||
|
||
```csharp
|
||
public static uint MapStatus(int status) => MapStatus((AbStatus)status);
|
||
|
||
public static uint MapStatus(AbStatus status) => status switch
|
||
{
|
||
AbStatus.Ok => Good,
|
||
AbStatus.Pending => GoodMoreData,
|
||
AbStatus.Timeout => BadTimeout,
|
||
AbStatus.NotFound => BadNodeIdUnknown,
|
||
AbStatus.NotAllowed => BadNotWritable,
|
||
AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall => BadOutOfRange,
|
||
AbStatus.Unsupported => BadNotSupported,
|
||
AbStatus.TypeMismatch => BadTypeMismatch,
|
||
AbStatus.DeviceStateConflict => BadDeviceFailure,
|
||
_ => BadCommunicationError, // BadConnection, BadReply, Partial, Abort
|
||
};
|
||
|
||
public static uint MapException(Exception ex) =>
|
||
ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError;
|
||
```
|
||
|
||
Rename every caller of `MapLibplctagStatus` to `MapStatus`; `grep -rn MapLibplctagStatus src tests` finds them all. Keep `MapCipGeneralStatus` unchanged.
|
||
|
||
`AbCipDriverProbe`:
|
||
|
||
```csharp
|
||
internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
|
||
internal static bool IsReachableException(AbTransportException ex) => IsReachableStatus(ex.Status);
|
||
```
|
||
|
||
In the probe body, replace `(Status)rt.GetStatus()` with `(AbStatus)rt.GetStatus()`, `statusCode == Status.Ok` with `== AbStatus.Ok`, and `catch (LibPlcTagException ex)` with `catch (AbTransportException ex)`. Replace `new LibplctagTagRuntime(p)` with `tagFactory.Create(p)`, where `tagFactory` is a new optional parameter (`IAbCipTagFactory? tagFactory = null` ⇒ `new LibplctagTagFactory()`). Task 15 passes the transport-selected factory.
|
||
|
||
`AbCipDriver.cs:1186`:
|
||
|
||
```csharp
|
||
if (ex is AbTransportException ate
|
||
&& ate.Status != AbStatus.Ok
|
||
&& AbCipDriverProbe.IsReachableStatus(ate.Status))
|
||
device.Backoff.RecordSuccess();
|
||
```
|
||
|
||
Remove `using libplctag;` from `AbCipDriver.cs` and add `using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;`. Rename log template placeholders `{LibplctagStatus}` → `{Status}`, and change the messages from "libplctag status" to "transport status".
|
||
|
||
- [ ] **Step 5: Migrate the tests.**
|
||
- In `FakeAbCipTag` and every test that sets `Status =` / `new LibPlcTagException("ErrorX")`: use `(int)AbStatus.X` / `new AbTransportException(AbStatus.X, "X")`.
|
||
- Map the old names this way: `ErrorNotFound→NotFound`, `ErrorTimeout→Timeout`, `ErrorBadConnection→BadConnection`, `ErrorNotAllowed→NotAllowed`, `ErrorOutOfBounds→OutOfBounds`.
|
||
- In `AbCipDriverProbeTests` line 174/188, change the theory to iterate `AbStatus` values. "something the wrapper has never said" becomes `new AbTransportException(AbStatus.BadConnection, "...")`, and the expectation is false.
|
||
|
||
- [ ] **Step 6: Run the suite.** Run `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests`. Expected: all pass, with the same test count as before plus the new translation tests.
|
||
|
||
- [ ] **Step 7: Commit.**
|
||
|
||
```bash
|
||
git add -A src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests
|
||
git commit -m "refactor(abcip): confine libplctag to its adapters behind AbStatus/AbTransportException"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: AbLegacy seam — decouple the driver from libplctag types
|
||
|
||
**Goal:** Do the same as Task 2 for AbLegacy. Only `LibplctagLegacyTagRuntime.cs` and `LibplctagLegacyStatusTranslation.cs` may reference libplctag.
|
||
|
||
**Files:**
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs`
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyStatusMapper.cs` (`MapStatus(AbStatus|int)`, `MapException`; keep `MapPcccStatus`)
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs` (`IsReachableStatus(AbStatus)`, catch `AbTransportException`, optional `IAbLegacyTagFactory` parameter defaulting to `new LibplctagLegacyTagFactory()`)
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs` (lines ~318-325: `MapLibplctagStatus`→`MapStatus`; log text "transport status"; any `LibPlcTagException` catch → `AbTransportException`)
|
||
- Modify (tests): `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyEvictOnFailureTests.cs`, `AbLegacyReadWriteTests.cs` (line 73), `AbLegacyHostAndStatusTests.cs`, `AbLegacyBitRmwTests.cs` (line 232), `AbLegacyArrayTests.cs` (line 115), `FakeAbLegacyTag.cs`. Run `grep -rln "libplctag" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --include=*.cs` and migrate every hit except the translation test and the parity oracle. Also grep `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy` for `cref=".*MapLibplctagStatus` and fix any match (CS1574).
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyStatusTranslationTests.cs`
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] `grep -l 'using libplctag' src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/*.cs` lists exactly `LibplctagLegacyTagRuntime.cs` and `LibplctagLegacyStatusTranslation.cs`.
|
||
- [ ] The translation table is identical to Task 2's, applied in the AbLegacy namespace. The table is duplicated, not shared, because each driver owns its own libplctag confinement and Task 22's eventual deletion removes both.
|
||
- [ ] `AbLegacyStatusMapper.MapStatus(AbStatus)` gives exactly the outputs the old `MapLibplctagStatus` gave for the corresponding members, proven by a parity table copied from the old switch before deleting it.
|
||
- [ ] The full `AbLegacy.Tests` suite is green.
|
||
|
||
**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests` → all pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Write the failing tests.** Copy Task 2's `LibplctagStatusTranslationTests` verbatim into `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/LibplctagLegacyStatusTranslationTests.cs`, and change the namespace to `ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests` and the class under test to `LibplctagLegacyStatusTranslation`. Add the mapper parity table to `AbLegacyHostAndStatusTests`, built from the old `AbLegacyStatusMapper.MapLibplctagStatus` switch at `AbLegacyStatusMapper.cs:42-55`.
|
||
- [ ] **Step 2: Run and confirm it fails.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter LibplctagLegacyStatusTranslation` → compile error.
|
||
- [ ] **Step 3: Implement.** Create `LibplctagLegacyStatusTranslation` with the same code as Task 2's `LibplctagStatusTranslation` (namespace `ZB.MOM.WW.OtOpcUa.Driver.AbLegacy`). Then make these changes:
|
||
- Wrap `InitializeAsync/ReadAsync/WriteAsync` in `LibplctagLegacyTagRuntime` with `LibplctagLegacyStatusTranslation.Wrap(...)`.
|
||
- `GetStatus() => (int)LibplctagLegacyStatusTranslation.ToAbStatus(_tag.GetStatus())`.
|
||
- The mapper becomes:
|
||
|
||
```csharp
|
||
public static uint MapStatus(int status) => MapStatus((AbStatus)status);
|
||
public static uint MapStatus(AbStatus status) => status switch
|
||
{
|
||
AbStatus.Ok => Good,
|
||
AbStatus.Pending => GoodMoreData,
|
||
AbStatus.Timeout => BadTimeout,
|
||
AbStatus.NotFound => BadNodeIdUnknown,
|
||
AbStatus.NotAllowed => BadNotWritable,
|
||
AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall => BadOutOfRange,
|
||
AbStatus.Unsupported => BadNotSupported,
|
||
AbStatus.TypeMismatch => BadTypeMismatch,
|
||
AbStatus.DeviceStateConflict => BadDeviceFailure,
|
||
_ => BadCommunicationError,
|
||
};
|
||
public static uint MapException(Exception ex) =>
|
||
ex is AbTransportException ate ? MapStatus(ate.Status) : BadCommunicationError;
|
||
```
|
||
|
||
If `AbLegacyStatusMapper` does not yet define `BadTypeMismatch`, add `public const uint BadTypeMismatch = 0x80740000u;` (the same value as `AbCipStatusMapper`).
|
||
- In the probe: `IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound`, and every `Status.*` use moves to `AbStatus.*`.
|
||
- [ ] **Step 4: Migrate the tests.** `FakeAbLegacyTag.Status` values and any `LibPlcTagException` move to `(int)AbStatus.X` / `AbTransportException`.
|
||
- [ ] **Step 5: Run the suite.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests` → all pass.
|
||
- [ ] **Step 6: Commit.**
|
||
|
||
```bash
|
||
git add -A src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests
|
||
git commit -m "refactor(ablegacy): confine libplctag to its adapter behind AbStatus/AbTransportException"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Capture golden frames (libplctag ⇄ ab_server)
|
||
|
||
**Goal:** Capture, once, the exact request/reply bytes that libplctag 1.5.2 exchanges with `ab_server` for every operation the native stack must reproduce. Store them as hex fixtures that Tasks 7 and 8 assert parity against.
|
||
|
||
**Files:**
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/README.md` (how each capture was taken: commit, image tag, command)
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/*.hex` (one file per exchange, as below)
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFile.cs` (loader)
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/CaptureFileTests.cs`
|
||
- Create: `scripts/ab-capture/capture.sh` (re-runnable recipe)
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] Each `.hex` file holds alternating `> ` (client→server) and `< ` (server→client) lines of **encapsulation frames**. Each line is one full EIP frame (the 24-byte header plus data) as uppercase hex with no spaces. Lines starting with `#` are comments.
|
||
- [ ] These captures exist and are non-empty:
|
||
- `logix-register-session.hex`
|
||
- `logix-forward-open.hex` (both a Large FO 0x5B and, if libplctag falls back or uses it, a 0x54)
|
||
- `logix-read-dint.hex`, `logix-read-dint-array16.hex`, `logix-read-string.hex`, `logix-read-bool.hex`
|
||
- `logix-write-dint.hex`, `logix-write-string.hex`
|
||
- `logix-read-unknown-tag.hex` (CIP 0x04, via patch 0002)
|
||
- `logix-tags-listing.hex` (`@tags`, via patch 0001)
|
||
- `logix-forward-close.hex`
|
||
- `micro800-read-dint.hex` (UCMM, no Forward Open)
|
||
- `slc500-read-n7.hex`, `slc500-write-n7.hex`, `slc500-read-f8.hex`, `micrologix-read-n7.hex`, `plc5-read-n7.hex`
|
||
- [ ] `CaptureFileTests` loads every `.hex` file, parses every frame header, and asserts that each frame's encapsulation length field equals its data length.
|
||
- [ ] `Captures/README.md` records, for each file: the libplctag version (1.5.2), the ab_server image (`otopcua-ab-server:libplctag-release`), the compose profile, and the libplctag tag string used.
|
||
- [ ] `Captures/README.md` has a **"Routing observed"** table, one row per family (controllogix, micro800, slc500, micrologix, plc5, plus logixpccc if reachable), with the columns: ForwardOpen yes/no · CIP request wrapped in Unconnected Send yes/no · route bytes · requestor-id bytes (PCCC). Tasks 9, 12 and 14 read their `Routing` choice from this table.
|
||
|
||
**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter CaptureFile` → pass, and `ls tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures/*.hex | wc -l` → ≥ 17.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Bring up each fixture on the docker host, one profile at a time** (they all bind :44818):
|
||
|
||
```bash
|
||
ssh dohertj2@10.100.0.35 'cd /opt/otopcua-abcip && docker compose --profile controllogix up -d'
|
||
```
|
||
|
||
If `/opt/otopcua-abcip` is missing or stale, rsync first. Read the fixture's `docs/drivers/AbServer-Test-Fixture.md` for the exact path:
|
||
|
||
```bash
|
||
rsync -av tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/ dohertj2@10.100.0.35:/opt/otopcua-abcip/
|
||
```
|
||
|
||
For AbLegacy, the stack lives at **`~/otopcua-ablegacy`** on the docker host, not `/opt` (`infra/README.md:41,68`). It is driven by `AB_LEGACY_COMPOSE_PROFILE=<slc500|micrologix|plc5> docker compose up -d`, and its source is `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/`. **Read that compose file for the published ports**; do not assume `:44818`. For containers that publish other ports, or run concurrently, capture inside the container's network namespace with the netshoot form in Step 2.
|
||
|
||
- [ ] **Step 2: Capture server-side** while driving libplctag from the Mac with the existing CLIs (`otopcua-abcip-cli` / `otopcua-ablegacy-cli`; run `dotnet run --project src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli -- --help` for the verbs):
|
||
|
||
```bash
|
||
ssh dohertj2@10.100.0.35 'sudo timeout 20 tcpdump -i any -U -w /tmp/cap.pcap tcp port 44818' &
|
||
dotnet run --project src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli -- read --gateway 10.100.0.35 --path 1,0 --tag TestDINT --type DInt
|
||
wait; scp dohertj2@10.100.0.35:/tmp/cap.pcap "$CAPDIR/logix-read-dint.pcap" # CAPDIR = a fresh empty dir under the session scratchpad
|
||
```
|
||
|
||
If the CLI flags differ, use the CLI's own `--help` output. Do not invent flags. If `sudo tcpdump` is unavailable on the host, use `docker run --rm --net=container:otopcua-ab-server-controllogix nicolaka/netshoot tcpdump -U -w - tcp port 44818 > cap.pcap` instead.
|
||
|
||
- [ ] **Step 3: Convert each pcap into the `.hex` line format.** Use `scripts/ab-capture/capture.sh`, which runs `tshark -r X.pcap -T fields -e tcp.srcport -e tcp.payload -Y 'tcp.len>0'`, re-joins segments into EIP frames using the 24-byte header length field, and emits `>`/`<` by direction (srcport 44818 ⇒ `<`). Install tshark on the Mac with `brew install wireshark` if it is absent.
|
||
|
||
- [ ] **Step 4: Write the loader and its test.**
|
||
|
||
```csharp
|
||
// Captures/CaptureFile.cs
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
|
||
|
||
internal sealed record CapturedFrame(bool FromClient, byte[] Bytes);
|
||
|
||
internal static class CaptureFile
|
||
{
|
||
public static string Dir => Path.Combine(AppContext.BaseDirectory, "Captures");
|
||
|
||
public static IReadOnlyList<CapturedFrame> Load(string name) =>
|
||
File.ReadAllLines(Path.Combine(Dir, name))
|
||
.Where(l => l.Length > 2 && l[0] is '>' or '<')
|
||
.Select(l => new CapturedFrame(l[0] == '>', Convert.FromHexString(l[2..].Trim())))
|
||
.ToList();
|
||
|
||
/// <summary>The CIP payload of a SendRRData / SendUnitData frame (the last CPF item's data,
|
||
/// minus the 2-byte sequence count for connected data).</summary>
|
||
public static byte[] CipPayload(CapturedFrame f)
|
||
{
|
||
var b = f.Bytes;
|
||
var cmd = BitConverter.ToUInt16(b, 0); // test-only; fixtures are LE on a LE host
|
||
var pos = 24 + 4 + 2; // header + interface handle + timeout
|
||
var itemCount = BitConverter.ToUInt16(b, pos); pos += 2;
|
||
byte[] last = [];
|
||
for (var i = 0; i < itemCount; i++)
|
||
{
|
||
var type = BitConverter.ToUInt16(b, pos);
|
||
var len = BitConverter.ToUInt16(b, pos + 2);
|
||
last = b.AsSpan(pos + 4, len).ToArray();
|
||
if (type == 0x00B1) last = last[2..]; // strip connected sequence count
|
||
pos += 4 + len;
|
||
}
|
||
return cmd is 0x6F or 0x70 ? last : [];
|
||
}
|
||
}
|
||
```
|
||
|
||
Add `<None Include="Captures\*.hex" CopyToOutputDirectory="PreserveNewest" />` to the test csproj. `CaptureFileTests` iterates `Directory.GetFiles(CaptureFile.Dir, "*.hex")` and asserts, for every frame, `BitConverter.ToUInt16(bytes, 2) == bytes.Length - 24`.
|
||
|
||
- [ ] **Step 5: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter CaptureFile` → pass.
|
||
|
||
- [ ] **Step 6: Tear down the fixtures** (`docker compose down`) and **commit.**
|
||
|
||
```bash
|
||
git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Captures tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/*.csproj scripts/ab-capture
|
||
git commit -m "test(ab-transport): golden libplctag<->ab_server frame captures for Logix, Micro800 and PCCC"
|
||
```
|
||
|
||
> ⚠️ These captures prove **parity with libplctag**, not correctness. The independent reference is the real-press captures already in `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/CipSymbolObjectRealControllerCaptureTests.cs` and `CipTemplateObjectWireCaptureTests.cs`. Tasks 13 and 21 use those.
|
||
|
||
---
|
||
|
||
### Task 5: EtherNet/IP encapsulation codec + `EncapsulationClient`
|
||
|
||
**Goal:** A TCP client that frames EIP encapsulation, registers a session, and sends SendRRData (unconnected) and SendUnitData (connected) requests with at most one outstanding request. It also includes a scriptable in-process fake server for tests.
|
||
|
||
**Files:**
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationHeader.cs`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationCodec.cs`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip/EncapsulationClient.cs`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationCodecTests.cs`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip/EncapsulationClientTests.cs`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeEipServer.cs`
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] `EncapsulationCodec.RegisterSession()` produces exactly `65 00 04 00 00000000 00000000 <8-byte context> 00000000 01 00 00 00`.
|
||
- [ ] `EncapsulationCodec.SendRRData(session, ctx, cip, timeoutSec)` produces: header with command `0x006F`; data = interface handle `00000000`, timeout `u16`, item count `02 00`, Null Address item `00 00 00 00`, then the Unconnected Data item `B2 00 <len u16>` followed by the cip bytes.
|
||
- [ ] `EncapsulationCodec.SendUnitData(session, connId, seq, cip)` produces: command `0x0070`; data = interface handle `00000000`, timeout `00 00`, item count `02 00`, Connected Address item `A1 00 04 00 <connId u32>`, Connected Data item `B1 00 <len+2 u16> <seq u16>` followed by the cip bytes.
|
||
- [ ] `EncapsulationCodec.ParseReply` returns the header and the CPF item payload. For a connected reply it strips the sequence count. It throws `AbTransportException(BadReply)` when encapsulation status ≠ 0, the length is wrong, or the expected item is missing.
|
||
- [ ] `EncapsulationClient`:
|
||
- **Lifecycle.** It connects, registers (and stores the session handle from the reply), and `UnRegisterSession`s on `DisposeAsync`.
|
||
- **Single flight.** It serializes requests with a `SemaphoreSlim(1,1)`.
|
||
- **Connected sequence (B1).** `SendUnitDataAsync(otConnectionId, cip, timeout, ct)` takes **no sequence argument**. The client owns the connected sequence counter and increments it **inside the gate**, wrapping 65535 → 1.
|
||
- **Matching replies.**
|
||
- Unconnected replies match on the 8-byte sender context.
|
||
- Connected (0x70) replies match on the echoed sequence count.
|
||
- Any frame that doesn't match (an earlier abandoned request's late reply) is **skipped** and the client keeps reading. It is not treated as an error.
|
||
- **Caller cancellation (B2).**
|
||
- The frame write uses `CancellationToken.None`, so a write is never half-sent.
|
||
- If the *caller's* token is cancelled during the reply wait, the call throws `OperationCanceledException`. The connection is **not** faulted: `IsConnected` stays true, the gate is released, and the late reply is skipped by the next exchange.
|
||
- **Faults.**
|
||
- The *timeout* elapsing (no reply within `timeout`) throws `AbTransportException(Timeout)` and **faults** the connection by closing the socket. A target that has stopped answering cannot be trusted.
|
||
- A socket or I/O error throws `AbTransportException(BadConnection)` and faults the connection.
|
||
- `IsConnected` turns false after any fault.
|
||
- [ ] Parity: the bytes `RegisterSession` sends, apart from the sender context, equal the first `>` frame of `Captures/logix-register-session.hex`.
|
||
- [ ] `FakeEipServer` accepts a script of `Func<byte[] request, byte[]? reply>` steps, records every request it receives, and can be told to stall (to test timeouts) or drop the connection.
|
||
|
||
**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~Eip"` → all pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Write the codec golden tests (failing).**
|
||
|
||
```csharp
|
||
// Eip/EncapsulationCodecTests.cs
|
||
using Shouldly;
|
||
using Xunit;
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Captures;
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Eip;
|
||
|
||
public sealed class EncapsulationCodecTests
|
||
{
|
||
private const ulong Ctx = 0x0807060504030201;
|
||
|
||
[Fact]
|
||
public void RegisterSession_frame_is_byte_exact() =>
|
||
Convert.ToHexString(EncapsulationCodec.RegisterSession(Ctx)).ShouldBe(
|
||
"6500" + "0400" + "00000000" + "00000000" + "0102030405060708" + "00000000" + "0100" + "0000");
|
||
|
||
[Fact]
|
||
public void SendRRData_wraps_cip_in_null_address_and_unconnected_data_items()
|
||
{
|
||
var frame = EncapsulationCodec.SendRRData(0x11223344, Ctx, new byte[] { 0x4C, 0x02 }, timeoutSeconds: 10);
|
||
Convert.ToHexString(frame).ShouldBe(
|
||
"6F00" + "1200" + "44332211" + "00000000" + "0102030405060708" + "00000000" +
|
||
"00000000" + "0A00" + "0200" + "00000000" + "B2000200" + "4C02");
|
||
}
|
||
|
||
[Fact]
|
||
public void SendUnitData_wraps_cip_with_connection_id_and_sequence()
|
||
{
|
||
var frame = EncapsulationCodec.SendUnitData(0x11223344, Ctx, connectionId: 0xAABBCCDD, sequence: 7, new byte[] { 0x4C, 0x02 });
|
||
Convert.ToHexString(frame).ShouldBe(
|
||
"7000" + "1800" + "44332211" + "00000000" + "0102030405060708" + "00000000" +
|
||
"00000000" + "0000" + "0200" + "A1000400" + "DDCCBBAA" + "B1000400" + "0700" + "4C02");
|
||
}
|
||
|
||
[Fact]
|
||
public void RegisterSession_matches_libplctag_capture_except_context()
|
||
{
|
||
var captured = CaptureFile.Load("logix-register-session.hex").First(f => f.FromClient).Bytes;
|
||
var ours = EncapsulationCodec.RegisterSession(Ctx);
|
||
ours.AsSpan(0, 12).ToArray().ShouldBe(captured.AsSpan(0, 12).ToArray()); // cmd,len,session,status
|
||
ours.AsSpan(20).ToArray().ShouldBe(captured.AsSpan(20).ToArray()); // options + data
|
||
}
|
||
|
||
[Fact]
|
||
public void Nonzero_encapsulation_status_is_BadReply()
|
||
{
|
||
var reply = Convert.FromHexString("6F00" + "0000" + "44332211" + "01000000" + "0102030405060708" + "00000000");
|
||
Should.Throw<AbTransportException>(() => EncapsulationCodec.ParseReply(reply, expectConnected: false))
|
||
.Status.ShouldBe(AbStatus.BadReply);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run and confirm it fails.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter FullyQualifiedName~Eip` → compile error.
|
||
|
||
- [ ] **Step 3: Implement the codec.**
|
||
|
||
```csharp
|
||
// Eip/EncapsulationHeader.cs
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
|
||
|
||
public static class EncapCommand
|
||
{
|
||
public const ushort ListIdentity = 0x0063, RegisterSession = 0x0065, UnRegisterSession = 0x0066,
|
||
SendRRData = 0x006F, SendUnitData = 0x0070;
|
||
}
|
||
|
||
public readonly record struct EncapsulationHeader(
|
||
ushort Command, ushort Length, uint SessionHandle, uint Status, ulong SenderContext, uint Options)
|
||
{
|
||
public const int Size = 24;
|
||
}
|
||
```
|
||
|
||
```csharp
|
||
// Eip/EncapsulationCodec.cs
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
|
||
|
||
public static class EncapsulationCodec
|
||
{
|
||
private static CipWriter Header(ushort cmd, uint session, ulong ctx) =>
|
||
new CipWriter().U16(cmd).U16(0).U32(session).U32(0).U64(ctx).U32(0);
|
||
|
||
private static byte[] Finish(CipWriter w)
|
||
{
|
||
w.PatchU16(2, (ushort)(w.Length - EncapsulationHeader.Size));
|
||
return w.ToArray();
|
||
}
|
||
|
||
public static byte[] RegisterSession(ulong ctx) =>
|
||
Finish(Header(EncapCommand.RegisterSession, 0, ctx).U16(1).U16(0));
|
||
|
||
public static byte[] UnRegisterSession(uint session, ulong ctx) =>
|
||
Finish(Header(EncapCommand.UnRegisterSession, session, ctx));
|
||
|
||
public static byte[] SendRRData(uint session, ulong ctx, ReadOnlySpan<byte> cip, ushort timeoutSeconds) =>
|
||
Finish(Header(EncapCommand.SendRRData, session, ctx)
|
||
.U32(0).U16(timeoutSeconds).U16(2)
|
||
.U16(0x0000).U16(0)
|
||
.U16(0x00B2).U16((ushort)cip.Length).Bytes(cip));
|
||
|
||
public static byte[] SendUnitData(uint session, ulong ctx, uint connectionId, ushort sequence, ReadOnlySpan<byte> cip) =>
|
||
Finish(Header(EncapCommand.SendUnitData, session, ctx)
|
||
.U32(0).U16(0).U16(2)
|
||
.U16(0x00A1).U16(4).U32(connectionId)
|
||
.U16(0x00B1).U16((ushort)(cip.Length + 2)).U16(sequence).Bytes(cip));
|
||
|
||
public static EncapsulationHeader ReadHeader(ReadOnlySpan<byte> b)
|
||
{
|
||
var r = new CipReader(b);
|
||
return new EncapsulationHeader(r.U16(), r.U16(), r.U32(), r.U32(), r.U64(), r.U32());
|
||
}
|
||
|
||
/// <summary>Parse a reply frame. Returns the header plus the CIP payload of the data item
|
||
/// (0x00B2 unconnected / 0x00B1 connected, sequence count stripped).</summary>
|
||
public static (EncapsulationHeader Header, ReadOnlyMemory<byte> Cip, ushort Sequence) ParseReply(
|
||
ReadOnlyMemory<byte> frame, bool expectConnected)
|
||
{
|
||
var h = ReadHeader(frame.Span);
|
||
if (h.Status != 0)
|
||
throw new AbTransportException(AbStatus.BadReply, $"Encapsulation status 0x{h.Status:X8} on command 0x{h.Command:X4}.");
|
||
if (h.Length != frame.Length - EncapsulationHeader.Size)
|
||
throw new AbTransportException(AbStatus.BadReply, "Encapsulation length mismatch.");
|
||
if (h.Command is EncapCommand.RegisterSession or EncapCommand.UnRegisterSession)
|
||
return (h, ReadOnlyMemory<byte>.Empty, 0);
|
||
|
||
var r = new CipReader(frame.Span[EncapsulationHeader.Size..]);
|
||
r.U32(); r.U16(); // interface handle, timeout
|
||
var items = r.U16();
|
||
var offset = EncapsulationHeader.Size + 8;
|
||
for (var i = 0; i < items; i++)
|
||
{
|
||
var type = r.U16();
|
||
var len = r.U16();
|
||
offset += 4;
|
||
if (type == (expectConnected ? 0x00B1 : 0x00B2))
|
||
{
|
||
if (!expectConnected) return (h, frame.Slice(offset, len), 0);
|
||
var seq = (ushort)(frame.Span[offset] | (frame.Span[offset + 1] << 8));
|
||
return (h, frame.Slice(offset + 2, len - 2), seq);
|
||
}
|
||
r.Bytes(len);
|
||
offset += len;
|
||
}
|
||
throw new AbTransportException(AbStatus.BadReply, "Reply carried no data item.");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Write the client tests against `FakeEipServer` (failing).**
|
||
|
||
`FakeEipServer` sketch (complete it):
|
||
|
||
```csharp
|
||
// Fakes/FakeEipServer.cs
|
||
using System.Net;
|
||
using System.Net.Sockets;
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
||
|
||
/// <summary>Loopback EIP endpoint. Each received frame is passed to Handler; a non-null return is
|
||
/// written back. Answers RegisterSession / UnRegisterSession automatically (session 0x11223344).</summary>
|
||
internal sealed class FakeEipServer : IAsyncDisposable
|
||
{
|
||
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
|
||
private readonly CancellationTokenSource _cts = new();
|
||
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||
public List<byte[]> Received { get; } = [];
|
||
public int Connections;
|
||
public Func<byte[], byte[]?> Handler { get; set; } = _ => null;
|
||
public bool Stall { get; set; }
|
||
|
||
public FakeEipServer() { _listener.Start(); _ = AcceptLoop(); }
|
||
|
||
private async Task AcceptLoop()
|
||
{
|
||
while (!_cts.IsCancellationRequested)
|
||
{
|
||
TcpClient c;
|
||
try { c = await _listener.AcceptTcpClientAsync(_cts.Token); } catch { return; }
|
||
Interlocked.Increment(ref Connections);
|
||
_ = Serve(c);
|
||
}
|
||
}
|
||
|
||
private async Task Serve(TcpClient c)
|
||
{
|
||
using var _ = c;
|
||
var s = c.GetStream();
|
||
var head = new byte[24];
|
||
while (!_cts.IsCancellationRequested)
|
||
{
|
||
if (!await ReadExact(s, head)) return;
|
||
var len = head[2] | (head[3] << 8);
|
||
var frame = new byte[24 + len];
|
||
head.CopyTo(frame, 0);
|
||
if (!await ReadExact(s, frame.AsMemory(24))) return;
|
||
lock (Received) Received.Add(frame);
|
||
var cmd = head[0] | (head[1] << 8);
|
||
byte[]? reply = cmd switch
|
||
{
|
||
0x65 => RegisterReply(frame),
|
||
0x66 => null,
|
||
_ => Stall ? null : Handler(frame),
|
||
};
|
||
if (reply is not null) await s.WriteAsync(reply, _cts.Token);
|
||
}
|
||
}
|
||
|
||
private static byte[] RegisterReply(byte[] req)
|
||
{
|
||
var r = (byte[])req.Clone();
|
||
r[4] = 0x44; r[5] = 0x33; r[6] = 0x22; r[7] = 0x11;
|
||
return r;
|
||
}
|
||
|
||
private async Task<bool> ReadExact(NetworkStream s, Memory<byte> buf)
|
||
{
|
||
var got = 0;
|
||
while (got < buf.Length)
|
||
{
|
||
int n;
|
||
try { n = await s.ReadAsync(buf[got..], _cts.Token); } catch { return false; }
|
||
if (n == 0) return false;
|
||
got += n;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/// <summary>Build a SendRRData reply echoing the request's session + context with the given CIP reply.</summary>
|
||
public static byte[] RRReply(byte[] request, byte[] cipReply)
|
||
{
|
||
var session = BitConverter.ToUInt32(request, 4);
|
||
var ctx = BitConverter.ToUInt64(request, 12);
|
||
return ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip.EncapsulationCodec.SendRRData(session, ctx, cipReply, 0);
|
||
}
|
||
|
||
public async ValueTask DisposeAsync() { _cts.Cancel(); _listener.Stop(); await Task.CompletedTask; }
|
||
}
|
||
```
|
||
|
||
Client tests to write:
|
||
- `Registers_and_stores_session_handle`: after `ConnectAsync`, `SessionHandle == 0x11223344`.
|
||
- `SendRRData_returns_cip_payload`: the handler replies `RRReply(req, [0xCC,0,0,0,0xC4,0,1,0,0,0])`, and the result equals those 10 bytes.
|
||
- `Concurrent_sends_are_serialized`: fire 5 `SendRRDataAsync` calls concurrently, with a handler that sleeps 20 ms and records the arrival time. Assert the received frames never overlap; each request arrives only after the previous reply was sent.
|
||
- `Stalled_reply_times_out_and_closes_the_socket`: set `Stall = true` and use a 200 ms timeout. Assert `AbTransportException(Timeout)` and `IsConnected == false`.
|
||
- `Caller_cancellation_does_not_fault_the_connection` (B2):
|
||
- Have the handler delay its reply by 300 ms, and cancel the caller's token at 50 ms.
|
||
- Assert `OperationCanceledException` and `IsConnected == true`.
|
||
- Then send a second request whose handler answers immediately. Assert it returns **its own** payload: the stale reply arrives first and is skipped.
|
||
- `Connected_sequence_is_assigned_inside_the_gate`: fire 20 concurrent `SendUnitDataAsync` calls, with a handler that echoes the sequence. Assert the sequences seen on the wire are strictly increasing in arrival order, with no duplicates.
|
||
- `Reply_with_foreign_context_is_skipped`: the handler first writes a reply carrying a different context, then the correct one. The call returns the correct payload.
|
||
- `Dispose_sends_UnRegisterSession`: after `DisposeAsync`, `Received` contains a 0x66 frame.
|
||
|
||
- [ ] **Step 5: Implement `EncapsulationClient`.**
|
||
|
||
```csharp
|
||
// Eip/EncapsulationClient.cs
|
||
using System.Net.Sockets;
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
|
||
|
||
public sealed class EncapsulationClient : IAsyncDisposable
|
||
{
|
||
private readonly TcpClient _tcp;
|
||
private readonly NetworkStream _stream;
|
||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||
private long _ctx;
|
||
private ushort _seq; // connected sequence count; only touched under _gate
|
||
private volatile bool _faulted;
|
||
|
||
public uint SessionHandle { get; private set; }
|
||
public bool IsConnected => !_faulted && _tcp.Connected;
|
||
|
||
private EncapsulationClient(TcpClient tcp) { _tcp = tcp; _stream = tcp.GetStream(); }
|
||
|
||
public static async Task<EncapsulationClient> ConnectAsync(string host, int port, TimeSpan timeout, CancellationToken ct)
|
||
{
|
||
var tcp = new TcpClient { NoDelay = true };
|
||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||
cts.CancelAfter(timeout);
|
||
try { await tcp.ConnectAsync(host, port, cts.Token).ConfigureAwait(false); }
|
||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||
{ tcp.Dispose(); throw new AbTransportException(AbStatus.Timeout, $"TCP connect to {host}:{port} timed out."); }
|
||
catch (SocketException ex)
|
||
{ tcp.Dispose(); throw new AbTransportException(AbStatus.BadConnection, $"TCP connect to {host}:{port} failed: {ex.SocketErrorCode}", ex); }
|
||
|
||
var c = new EncapsulationClient(tcp);
|
||
var reply = await c.ExchangeAsync(ctx => EncapsulationCodec.RegisterSession(ctx), timeout, ct).ConfigureAwait(false);
|
||
c.SessionHandle = EncapsulationCodec.ReadHeader(reply.Span).SessionHandle;
|
||
return c;
|
||
}
|
||
|
||
public async Task<ReadOnlyMemory<byte>> SendRRDataAsync(ReadOnlyMemory<byte> cip, TimeSpan timeout, CancellationToken ct)
|
||
{
|
||
var secs = (ushort)Math.Clamp((int)Math.Ceiling(timeout.TotalSeconds), 1, ushort.MaxValue);
|
||
var frame = await ExchangeAsync(ctx => EncapsulationCodec.SendRRData(SessionHandle, ctx, cip.Span, secs), timeout, ct).ConfigureAwait(false);
|
||
return EncapsulationCodec.ParseReply(frame, expectConnected: false).Cip;
|
||
}
|
||
|
||
public async Task<ReadOnlyMemory<byte>> SendUnitDataAsync(uint otConnectionId, ReadOnlyMemory<byte> cip,
|
||
TimeSpan timeout, CancellationToken ct)
|
||
{
|
||
// The sequence is chosen inside ExchangeAsync's gate (build runs under it), so concurrent
|
||
// callers can never put sequences on the wire out of order (review B1).
|
||
var frame = await ExchangeAsync(ctx =>
|
||
{
|
||
_seq = _seq == ushort.MaxValue ? (ushort)1 : (ushort)(_seq + 1);
|
||
return EncapsulationCodec.SendUnitData(SessionHandle, ctx, otConnectionId, _seq, cip.Span);
|
||
}, timeout, ct).ConfigureAwait(false);
|
||
return EncapsulationCodec.ParseReply(frame, expectConnected: true).Cip;
|
||
}
|
||
|
||
/// <summary>Write one frame, then read frames until the matching reply arrives. Unconnected
|
||
/// replies match on sender context; connected (0x70) replies match on the echoed sequence.
|
||
/// Non-matching frames are late replies to abandoned requests and are skipped.
|
||
/// Caller cancellation abandons the wait WITHOUT faulting the stream (review B2); only the
|
||
/// timeout, an I/O error or a malformed frame faults it.</summary>
|
||
private async Task<ReadOnlyMemory<byte>> ExchangeAsync(Func<ulong, byte[]> build, TimeSpan timeout, CancellationToken ct)
|
||
{
|
||
if (_faulted) throw new AbTransportException(AbStatus.BadConnection, "Session is faulted; reconnect.");
|
||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||
using var timeoutCts = new CancellationTokenSource(timeout);
|
||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
|
||
try
|
||
{
|
||
var ctx = (ulong)Interlocked.Increment(ref _ctx);
|
||
var request = build(ctx);
|
||
var cmd = (ushort)(request[0] | (request[1] << 8));
|
||
var expectSeq = cmd == EncapCommand.SendUnitData ? _seq : (ushort)0; // set by build() above, under the gate
|
||
await _stream.WriteAsync(request, CancellationToken.None).ConfigureAwait(false);
|
||
while (true)
|
||
{
|
||
var frame = await ReadFrameAsync(linked.Token).ConfigureAwait(false);
|
||
var h = EncapsulationCodec.ReadHeader(frame.Span);
|
||
if (h.Command != cmd) continue;
|
||
if (cmd == EncapCommand.SendUnitData)
|
||
{
|
||
if (EncapsulationCodec.ParseReply(frame, expectConnected: true).Sequence != expectSeq) continue;
|
||
}
|
||
else if (h.SenderContext != ctx) continue;
|
||
return frame;
|
||
}
|
||
}
|
||
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)
|
||
{ Fault(); throw new AbTransportException(AbStatus.Timeout, $"No reply within {timeout.TotalMilliseconds:F0} ms."); }
|
||
catch (OperationCanceledException) { throw; } // caller cancelled: keep the stream; the late reply is skipped later
|
||
catch (AbTransportException) { Fault(); throw; }
|
||
catch (Exception ex) when (ex is IOException or SocketException or ObjectDisposedException or EndOfStreamException)
|
||
{ Fault(); throw new AbTransportException(AbStatus.BadConnection, ex.Message, ex); }
|
||
finally { _gate.Release(); }
|
||
}
|
||
|
||
private async Task<ReadOnlyMemory<byte>> ReadFrameAsync(CancellationToken ct)
|
||
{
|
||
var head = new byte[EncapsulationHeader.Size];
|
||
await _stream.ReadExactlyAsync(head, ct).ConfigureAwait(false);
|
||
var len = head[2] | (head[3] << 8);
|
||
var frame = new byte[EncapsulationHeader.Size + len];
|
||
head.CopyTo(frame, 0);
|
||
await _stream.ReadExactlyAsync(frame.AsMemory(EncapsulationHeader.Size), ct).ConfigureAwait(false);
|
||
return frame;
|
||
}
|
||
|
||
private void Fault() { _faulted = true; try { _tcp.Close(); } catch { /* already closed */ } }
|
||
|
||
public async ValueTask DisposeAsync()
|
||
{
|
||
if (!_faulted && _tcp.Connected)
|
||
{
|
||
try
|
||
{
|
||
var bytes = EncapsulationCodec.UnRegisterSession(SessionHandle, 0);
|
||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||
await _stream.WriteAsync(bytes, cts.Token).ConfigureAwait(false);
|
||
}
|
||
catch { /* best effort */ }
|
||
}
|
||
_tcp.Dispose();
|
||
_gate.Dispose();
|
||
}
|
||
}
|
||
```
|
||
|
||
> Note: a *caller* cancellation leaves the stream intact. The abandoned reply is skipped when it arrives. Only a timeout or an I/O fault closes the socket, and `CipConnection` (Task 9) then reconnects through the Forward Open throttle. This distinction is press safety: unsubscribing an OPC UA monitored item must not cost the press a new Forward Open.
|
||
|
||
- [ ] **Step 6: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter FullyQualifiedName~Eip` → all pass.
|
||
- [ ] **Step 7: Commit.** `git add -A src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Eip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Eip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes && git commit -m "feat(ab-transport): EtherNet/IP encapsulation codec and single-flight session client"`
|
||
|
||
---
|
||
|
||
### Task 6: CIP path encoding + Logix type codes
|
||
|
||
**Goal:** Pure encoders for (a) the route from a CIP path string such as `1,0` and (b) the request path for a Logix symbolic tag name. Also provide the Logix atomic type-code table that the native runtime uses for writes and for the type check.
|
||
|
||
**Files:**
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTypeCode.cs`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipPathTests.cs`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTypeCodeTests.cs`
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] `CipPath.EncodeRoute("1,0")` → `01 00`. `EncodeRoute("")` → empty. `EncodeRoute("1,0,2,10.0.0.5")` → `01 00 12 08 31 30 2E 30 2E 30 2E 35`. That is port 2 with the extended-link-address flag `0x10 | 2` = `0x12`, length 8, the ASCII text, and no pad because the length is even. A 9-character address gets one pad byte. Port numbers ≥ 15 use the extended-port form `0x0F, u16 port`.
|
||
- [ ] `CipPath.EncodeSymbolic("TestDINT")` → `91 08 54 65 73 74 44 49 4E 54`. An odd-length name gets a pad byte after the name.
|
||
- [ ] `EncodeSymbolic("Program:Main.Arr[3].Val")` → `91 0C "Program:Main"`, then `91 03 "Arr" 00`, then element `28 03`, then `91 03 "Val" 00`.
|
||
- [ ] Element segments use `28 xx` for indices < 256, `29 00 lo hi` for indices < 65536, and `2A 00 b0 b1 b2 b3` above that. A multi-dimensional `[1,2]` emits two element segments.
|
||
- [ ] A trailing purely numeric member (`Tag.5`, the bit selector) is **not** encoded. `EncodeSymbolic` throws `ArgumentException`, and `CipPath.SplitBitSelector("Tag.5")` returns `("Tag", 5)`. Bit selection is the runtime's job (Task 12).
|
||
- [ ] `CipPath.EncodeClassInstance(cls, inst)` uses 8-bit logical segments (`20 cc 24 ii`) when they fit, otherwise 16-bit (`21 00 cc cc` / `25 00 ii ii`).
|
||
- [ ] `CipPath.WithPathSize(segments)` prefixes the size in 16-bit words.
|
||
- [ ] `LogixTypeCode` has these constants: `Bool=0xC1, SInt=0xC2, Int=0xC3, DInt=0xC4, LInt=0xC5, USInt=0xC6, UInt=0xC7, UDInt=0xC8, ULInt=0xC9, Real=0xCA, LReal=0xCB, Struct=0x02A0, BoolArray=0xD3`. `ByteWidth(code)` returns 1/1/2/4/8/1/2/4/8/4/8/4.
|
||
|
||
**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~CipPath|FullyQualifiedName~LogixTypeCode"` → pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Write the failing tests** for every acceptance-criteria row above. Each one is an `[InlineData(input, expectedHex)]` theory asserting `Convert.ToHexString(CipPath.EncodeX(input)).ShouldBe(expectedHex)`. For example:
|
||
|
||
```csharp
|
||
[Theory]
|
||
[InlineData("TestDINT", "9108546573744449 4E54")]
|
||
[InlineData("Program:Main.Arr[3].Val", "910C50726F6772616D3A4D61696E" + "910341727200" + "2803" + "910356616C00")]
|
||
[InlineData("Big[300]", "910342696700" + "29002C01")]
|
||
[InlineData("Grid[1,2]", "910447726964" + "2801" + "2802")]
|
||
public void Encodes_symbolic(string name, string hex) =>
|
||
Convert.ToHexString(CipPath.EncodeSymbolic(name)).ShouldBe(hex.Replace(" ", ""));
|
||
```
|
||
|
||
- [ ] **Step 2: Run and confirm it fails.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~CipPath|FullyQualifiedName~LogixTypeCode"` → compile error.
|
||
|
||
- [ ] **Step 3: Implement.**
|
||
|
||
```csharp
|
||
// Cip/CipPath.cs
|
||
using System.Globalization;
|
||
using System.Text;
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||
|
||
public static class CipPath
|
||
{
|
||
/// <summary>Route path from "port,link[,port,link…]". Links that are not a byte value are IP
|
||
/// addresses, encoded as extended link-address port segments.</summary>
|
||
public static byte[] EncodeRoute(string cipPath)
|
||
{
|
||
var w = new CipWriter();
|
||
if (string.IsNullOrWhiteSpace(cipPath)) return [];
|
||
var parts = cipPath.Split(',', StringSplitOptions.TrimEntries);
|
||
if (parts.Length % 2 != 0) throw new ArgumentException($"CIP path '{cipPath}' must be port,link pairs.");
|
||
for (var i = 0; i < parts.Length; i += 2)
|
||
{
|
||
var port = int.Parse(parts[i], CultureInfo.InvariantCulture);
|
||
var link = parts[i + 1];
|
||
var isByte = byte.TryParse(link, NumberStyles.None, CultureInfo.InvariantCulture, out var linkByte);
|
||
if (port < 15)
|
||
{
|
||
if (isByte) w.U8((byte)port).U8(linkByte);
|
||
else { var a = Encoding.ASCII.GetBytes(link); w.U8((byte)(0x10 | port)).U8((byte)a.Length).Bytes(a).PadToEven(); }
|
||
}
|
||
else
|
||
{
|
||
if (isByte) w.U8(0x0F).U16((ushort)port).U8(linkByte);
|
||
else { var a = Encoding.ASCII.GetBytes(link); w.U8(0x1F).U8((byte)a.Length).U16((ushort)port).Bytes(a).PadToEven(); }
|
||
}
|
||
}
|
||
return w.ToArray();
|
||
}
|
||
|
||
public static (string Name, int? Bit) SplitBitSelector(string tagName)
|
||
{
|
||
var dot = tagName.LastIndexOf('.');
|
||
if (dot > 0 && dot < tagName.Length - 1 && tagName[(dot + 1)..].All(char.IsAsciiDigit))
|
||
return (tagName[..dot], int.Parse(tagName[(dot + 1)..], CultureInfo.InvariantCulture));
|
||
return (tagName, null);
|
||
}
|
||
|
||
public static byte[] EncodeSymbolic(string tagName)
|
||
{
|
||
if (SplitBitSelector(tagName).Bit is not null)
|
||
throw new ArgumentException($"'{tagName}' ends in a bit selector; strip it with SplitBitSelector first.");
|
||
var w = new CipWriter();
|
||
foreach (var part in SplitMembers(tagName))
|
||
{
|
||
var bracket = part.IndexOf('[');
|
||
var name = bracket < 0 ? part : part[..bracket];
|
||
var a = Encoding.ASCII.GetBytes(name);
|
||
w.U8(0x91).U8((byte)a.Length).Bytes(a).PadToEven();
|
||
if (bracket >= 0)
|
||
{
|
||
foreach (var idxText in part[(bracket + 1)..part.IndexOf(']')].Split(',', StringSplitOptions.TrimEntries))
|
||
{
|
||
var idx = uint.Parse(idxText, CultureInfo.InvariantCulture);
|
||
if (idx < 0x100) w.U8(0x28).U8((byte)idx);
|
||
else if (idx < 0x10000) w.U8(0x29).U8(0).U16((ushort)idx);
|
||
else w.U8(0x2A).U8(0).U32(idx);
|
||
}
|
||
}
|
||
}
|
||
return w.ToArray();
|
||
}
|
||
|
||
/// <summary>Split on '.' outside brackets, keeping "Program:X" whole.</summary>
|
||
private static IEnumerable<string> SplitMembers(string name)
|
||
{
|
||
var depth = 0; var start = 0;
|
||
for (var i = 0; i < name.Length; i++)
|
||
{
|
||
if (name[i] == '[') depth++;
|
||
else if (name[i] == ']') depth--;
|
||
else if (name[i] == '.' && depth == 0) { yield return name[start..i]; start = i + 1; }
|
||
}
|
||
yield return name[start..];
|
||
}
|
||
|
||
public static byte[] EncodeClassInstance(ushort cls, uint instance)
|
||
{
|
||
var w = new CipWriter();
|
||
if (cls < 0x100) w.U8(0x20).U8((byte)cls); else w.U8(0x21).U8(0).U16(cls);
|
||
if (instance < 0x100) w.U8(0x24).U8((byte)instance);
|
||
else if (instance < 0x10000) w.U8(0x25).U8(0).U16((ushort)instance);
|
||
else w.U8(0x26).U8(0).U32(instance);
|
||
return w.ToArray();
|
||
}
|
||
|
||
/// <summary>Message Router (class 2, instance 1).</summary>
|
||
public static readonly byte[] MessageRouter = [0x20, 0x02, 0x24, 0x01];
|
||
|
||
/// <summary>Connection Manager (class 6, instance 1).</summary>
|
||
public static readonly byte[] ConnectionManager = [0x20, 0x06, 0x24, 0x01];
|
||
}
|
||
```
|
||
|
||
```csharp
|
||
// Logix/LogixTypeCode.cs
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
|
||
|
||
public static class LogixTypeCode
|
||
{
|
||
public const ushort Bool = 0xC1, SInt = 0xC2, Int = 0xC3, DInt = 0xC4, LInt = 0xC5, USInt = 0xC6,
|
||
UInt = 0xC7, UDInt = 0xC8, ULInt = 0xC9, Real = 0xCA, LReal = 0xCB, BoolArray = 0xD3, Struct = 0x02A0;
|
||
|
||
public static int ByteWidth(ushort code) => code switch
|
||
{
|
||
Bool or SInt or USInt => 1,
|
||
Int or UInt => 2,
|
||
DInt or UDInt or Real or BoolArray => 4,
|
||
LInt or ULInt or LReal => 8,
|
||
_ => -1,
|
||
};
|
||
}
|
||
```
|
||
|
||
Fix the `"9108546573744449 4E54"` spacing in the test by stripping spaces (already done with `.Replace(" ", "")`).
|
||
|
||
- [ ] **Step 4: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~CipPath|FullyQualifiedName~LogixTypeCode"` → pass.
|
||
- [ ] **Step 5: Commit.** `git commit -am`-style, with explicit paths: `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipPath.cs src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix && git commit -m "feat(ab-transport): CIP route/symbolic path encoders and Logix type codes"`
|
||
|
||
---
|
||
|
||
### Task 7: CIP service codecs (Connection Manager, MSP, Logix Read/Write, symbol + template)
|
||
|
||
**Goal:** Pure request builders and reply parsers for every CIP service the native AbCip path needs, each proven byte-exact against the Task 4 captures where a capture exists.
|
||
|
||
**Files:**
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionManagerServices.cs` (Unconnected Send 0x52, Forward Open 0x54, Large Forward Open 0x5B, Forward Close 0x4E)
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/MultipleServicePacket.cs` (0x0A)
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixServices.cs` (Read Tag 0x4C, Read Tag Fragmented 0x52, Write Tag 0x4D, Write Tag Fragmented 0x53)
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/SymbolObjectServices.cs` (Get Instance Attribute List 0x55 on class 0x6B)
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/TemplateObjectServices.cs` (Get Attribute List 0x03 + Read 0x4C on class 0x6C)
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ConnectionManagerServicesTests.cs`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/MultipleServicePacketTests.cs`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixServicesTests.cs`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/SymbolObjectServicesTests.cs`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/TemplateObjectServicesTests.cs`
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] **Unconnected Send** `0x52` to `ConnectionManager` builds: priority/tick `0x0A`, timeout ticks `0x0E`, embedded request size `u16`, the embedded request, a pad byte if that size is odd, the route path size in words `u8`, reserved `0x00`, then the route. When the route is empty (Micro800), the builder is not used and the embedded request is sent directly.
|
||
- [ ] **Forward Open.** `ForwardOpen(ForwardOpenParams p)` emits:
|
||
- service `0x54` (or `0x5B` when `p.Large`), path size `02`, `ConnectionManager`, priority/tick `0x0A`, timeout ticks `0x0E`;
|
||
- O→T id `00000000`, T→O id `p.TOConnectionId`, connection serial `p.ConnectionSerial`, vendor id `p.VendorId`, originator serial `p.OriginatorSerial`;
|
||
- timeout multiplier `p.TimeoutMultiplier`, 3 reserved bytes;
|
||
- O→T RPI `u32`, then O→T params: `u16 0x4200 | size` (regular) or `u32 0x42000000 | size` (large);
|
||
- T→O RPI, then T→O params (same encoding);
|
||
- transport/trigger `0xA3`, connection path size in words, connection path = `EncodeRoute(cipPath)` + `MessageRouter`.
|
||
- [ ] `ParseForwardOpenReply` returns `(OTConnectionId, TOConnectionId, OTApiMicros, TOApiMicros)` on success. On a refusal it surfaces the general and extended status.
|
||
- [ ] `ShouldFallBackToRegularOpen(CipReply)` is true when the reply is general `0x08` (Service Not Supported — pre-v20 Logix and other targets answer a Large Forward Open this way, and libplctag falls back on it too) **or** general `0x01` with extended `0x0109` (invalid connection size). Task 9 uses it.
|
||
- [ ] `ForwardClose(serial, vendor, origSerial, route)` → `4E 02 20 06 24 01 0A 0E <serial> <vendor> <origSerial> <pathWords> 00 <route + MessageRouter>`.
|
||
- [ ] **MSP.** `MultipleServicePacket.Build(IReadOnlyList<byte[]> requests)` → `0A 02 20 02 24 01`, count `u16`, then offsets `u16[count]` measured from the start of the count field, then the requests concatenated. `Parse(reply, count)` returns one `CipReply` per embedded reply. `BuildSize(sizes)` returns the exact byte size of an MSP for the given request sizes, without allocating (Task 10 uses it for packing).
|
||
- [ ] **Read/Write Tag:**
|
||
- `ReadTag(path, elements)` → `4C <pathWords> <path> <elements u16>`.
|
||
- `ReadTagFragmented(path, elements, offset)` → `52 … <elements u16> <offset u32>`.
|
||
- `WriteTag(path, typeCode, structHandle?, elements, data)` → `4D … <type u16> [<handle u16> when type == 0x02A0] <elements u16> <data>`.
|
||
- `WriteTagFragmented` → `53 … <type> [<handle>] <elements> <offset u32> <data>`.
|
||
- `ParseReadReply(CipReply)` → `(ushort TypeCode, ushort? StructHandle, ReadOnlyMemory<byte> Data)`. For `0x02A0` the next u16 is the handle.
|
||
- [ ] **Symbol listing.** `SymbolObjectServices.GetInstanceAttributeList(programName?, startInstance)` →
|
||
- `[91 len "Program:X" pad]`, then `20 6B 25 00 <start u16>`, then attribute count `04 00` and attributes `02 00 07 00 08 00 01 00` (type, element length, array dims, name).
|
||
- Each reply entry, as the controller sends it, is `u32 instance, u16 type, u16 elemLen, u32×3 dims, u16 nameLen, name` — exactly the entry layout `CipSymbolObjectDecoder` expects. `ParseListingReply` returns `(byte[] Entries, uint LastInstance, bool More)`, where More ⇔ general status `0x06`.
|
||
- ⚠️ If `Captures/logix-tags-listing.hex` shows libplctag requesting a different attribute set or order, **match the capture** and update this criterion. The decoder layout is the invariant; the attribute list is the means.
|
||
- [ ] **Templates:**
|
||
- `TemplateObjectServices.GetAttributes(templateId)` → `03 03 20 6C 25 00 <id u16> 04 00 04 00 05 00 02 00 01 00`. That is attributes 4 (definition size, words), 5 (structure size, bytes), 2 (member count) and 1 (handle).
|
||
- `ParseAttributes` → `(uint DefWords, uint StructBytes, ushort Members, ushort Handle)`; each attribute's reply is `id u16, status u16, value`.
|
||
- `ReadTemplate(templateId, offset, bytes)` → `4C 03 20 6C 25 00 <id u16> <offset u32> <bytes u16>`, with More ⇔ status `0x06`.
|
||
- `TemplateObjectServices.BuildDecoderBuffer(id, attrs, rawTemplate)` → the 14-byte header + raw bytes from Global Constraints.
|
||
- [ ] **Capture parity tests** (each extracts the CIP payload via `CaptureFile.CipPayload`):
|
||
- The ReadTag request in `logix-read-dint.hex` equals our `ReadTag(EncodeSymbolic("TestDINT"), 1)`. If libplctag wrapped it in an MSP or Unconnected Send, unwrap the same way in the test, and document that wrapping in a test comment.
|
||
- `ParseReadReply` on the captured reply yields `TypeCode == 0xC4` and 4 data bytes.
|
||
- The captured Forward Open request equals ours when built with the captured values of the serials and connection ids, which are read out of the capture.
|
||
- The captured `@tags` reply entries decode through `CipSymbolObjectDecoder` (reference the AbCip project from the test project only if that is needed; otherwise assert the entry byte layout directly).
|
||
|
||
**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~Services|FullyQualifiedName~MultipleServicePacket"` → pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Write the golden-byte tests (failing)** for each builder above, one `[Fact]` per acceptance row. For example:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void Forward_close_frame()
|
||
{
|
||
var bytes = ConnectionManagerServices.ForwardClose(connectionSerial: 0x1234, vendorId: 0x1337, originatorSerial: 0xCAFEBABE, route: [0x01, 0x00]);
|
||
Convert.ToHexString(bytes).ShouldBe("4E02200624010A0E" + "3412" + "3713" + "BEBAFECA" + "03" + "00" + "0100" + "20022401");
|
||
}
|
||
|
||
[Fact]
|
||
public void Msp_offsets_are_measured_from_the_count_field()
|
||
{
|
||
var a = new byte[] { 0x4C, 0x02, 0x91, 0x01, 0x41, 0x00, 0x01, 0x00 }; // 8 bytes
|
||
var b = new byte[] { 0x4C, 0x02, 0x91, 0x01, 0x42, 0x00, 0x01, 0x00 };
|
||
Convert.ToHexString(MultipleServicePacket.Build([a, b])).ShouldBe(
|
||
"0A02200224010200" + "0600" + "0E00" + Convert.ToHexString(a) + Convert.ToHexString(b));
|
||
MultipleServicePacket.BuildSize([8, 8]).ShouldBe(6 + 2 + 4 + 16);
|
||
}
|
||
|
||
[Fact]
|
||
public void Read_reply_with_struct_handle()
|
||
{
|
||
var reply = CipReply.Parse(Convert.FromHexString("CC000000" + "A002" + "CE0F" + "05000000" + "48656C6C6F"));
|
||
var (type, handle, data) = LogixServices.ParseReadReply(reply);
|
||
type.ShouldBe((ushort)0x02A0); handle.ShouldBe((ushort)0x0FCE); data.Length.ShouldBe(9);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~Services|FullyQualifiedName~MultipleServicePacket"` → compile error.
|
||
|
||
- [ ] **Step 3: Implement.** The core shapes follow; complete every builder named in the acceptance criteria.
|
||
|
||
```csharp
|
||
// Cip/ConnectionManagerServices.cs
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||
|
||
public sealed record ForwardOpenParams(
|
||
bool Large, uint TOConnectionId, ushort ConnectionSerial, ushort VendorId, uint OriginatorSerial,
|
||
byte TimeoutMultiplier, uint RpiMicros, int ConnectionSize, byte[] Route);
|
||
|
||
public sealed record ForwardOpenReply(uint OTConnectionId, uint TOConnectionId, uint OTApiMicros, uint TOApiMicros);
|
||
|
||
public static class ConnectionManagerServices
|
||
{
|
||
public const byte PriorityTick = 0x0A, TimeoutTicks = 0x0E;
|
||
|
||
public static byte[] UnconnectedSend(ReadOnlySpan<byte> embedded, ReadOnlySpan<byte> route)
|
||
{
|
||
var w = new CipWriter().U8(0x52).U8(2).Bytes(CipPath.ConnectionManager)
|
||
.U8(PriorityTick).U8(TimeoutTicks).U16((ushort)embedded.Length).Bytes(embedded);
|
||
if ((embedded.Length & 1) == 1) w.U8(0);
|
||
return w.U8((byte)(route.Length / 2)).U8(0).Bytes(route).ToArray();
|
||
}
|
||
|
||
public static byte[] ForwardOpen(ForwardOpenParams p)
|
||
{
|
||
var connPath = new CipWriter().Bytes(p.Route).Bytes(CipPath.MessageRouter).ToArray();
|
||
var w = new CipWriter().U8(p.Large ? (byte)0x5B : (byte)0x54).U8(2).Bytes(CipPath.ConnectionManager)
|
||
.U8(PriorityTick).U8(TimeoutTicks)
|
||
.U32(0).U32(p.TOConnectionId).U16(p.ConnectionSerial).U16(p.VendorId).U32(p.OriginatorSerial)
|
||
.U8(p.TimeoutMultiplier).U8(0).U8(0).U8(0);
|
||
WriteSide(w, p);
|
||
WriteSide(w, p);
|
||
return w.U8(0xA3).U8((byte)(connPath.Length / 2)).Bytes(connPath).ToArray();
|
||
}
|
||
|
||
private static void WriteSide(CipWriter w, ForwardOpenParams p)
|
||
{
|
||
w.U32(p.RpiMicros);
|
||
if (p.Large) w.U32(0x42000000u | (uint)p.ConnectionSize);
|
||
else w.U16((ushort)(0x4200 | (p.ConnectionSize & 0x1FF)));
|
||
}
|
||
|
||
public static ForwardOpenReply ParseForwardOpenReply(CipReply reply)
|
||
{
|
||
if (reply.GeneralStatus != 0)
|
||
throw new AbTransportException(reply.Status == AbStatus.BadReply ? AbStatus.BadConnection : reply.Status,
|
||
$"Forward Open refused: general 0x{reply.GeneralStatus:X2}, extended {string.Join(",", reply.ExtendedStatus.Select(e => $"0x{e:X4}"))}.");
|
||
var r = new CipReader(reply.Data.Span);
|
||
var ot = r.U32(); var to = r.U32(); r.U16(); r.U16(); r.U32();
|
||
return new ForwardOpenReply(ot, to, r.U32(), r.U32());
|
||
}
|
||
|
||
public static byte[] ForwardClose(ushort connectionSerial, ushort vendorId, uint originatorSerial, ReadOnlySpan<byte> route)
|
||
{
|
||
var connPath = new CipWriter().Bytes(route).Bytes(CipPath.MessageRouter).ToArray();
|
||
return new CipWriter().U8(0x4E).U8(2).Bytes(CipPath.ConnectionManager).U8(PriorityTick).U8(TimeoutTicks)
|
||
.U16(connectionSerial).U16(vendorId).U32(originatorSerial)
|
||
.U8((byte)(connPath.Length / 2)).U8(0).Bytes(connPath).ToArray();
|
||
}
|
||
}
|
||
```
|
||
|
||
> The large-open fallback needs the raw statuses, so add:
|
||
> `public static bool ShouldFallBackToRegularOpen(CipReply r) => r.GeneralStatus == 0x08 || (r.GeneralStatus == 0x01 && r.ExtendedStatus.Length > 0 && r.ExtendedStatus[0] == 0x0109);`
|
||
> Test both branches, plus a negative case (`0x01`/`0x0100` "connection in use" must NOT fall back).
|
||
|
||
```csharp
|
||
// Logix/LogixServices.cs
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
|
||
|
||
public static class LogixServices
|
||
{
|
||
private static CipWriter Start(byte service, ReadOnlySpan<byte> path) =>
|
||
new CipWriter().U8(service).U8((byte)(path.Length / 2)).Bytes(path);
|
||
|
||
public static byte[] ReadTag(ReadOnlySpan<byte> path, ushort elements) => Start(0x4C, path).U16(elements).ToArray();
|
||
public static byte[] ReadTagFragmented(ReadOnlySpan<byte> path, ushort elements, uint offset) =>
|
||
Start(0x52, path).U16(elements).U32(offset).ToArray();
|
||
|
||
public static byte[] WriteTag(ReadOnlySpan<byte> path, ushort type, ushort? handle, ushort elements, ReadOnlySpan<byte> data)
|
||
{
|
||
var w = Start(0x4D, path).U16(type);
|
||
if (type == LogixTypeCode.Struct) w.U16(handle ?? throw new ArgumentException("Struct write needs a handle."));
|
||
return w.U16(elements).Bytes(data).ToArray();
|
||
}
|
||
|
||
public static byte[] WriteTagFragmented(ReadOnlySpan<byte> path, ushort type, ushort? handle, ushort elements, uint offset, ReadOnlySpan<byte> data)
|
||
{
|
||
var w = Start(0x53, path).U16(type);
|
||
if (type == LogixTypeCode.Struct) w.U16(handle ?? throw new ArgumentException("Struct write needs a handle."));
|
||
return w.U16(elements).U32(offset).Bytes(data).ToArray();
|
||
}
|
||
|
||
public static (ushort TypeCode, ushort? StructHandle, ReadOnlyMemory<byte> Data) ParseReadReply(CipReply reply)
|
||
{
|
||
var r = new CipReader(reply.Data.Span);
|
||
var type = r.U16();
|
||
ushort? handle = type == LogixTypeCode.Struct ? r.U16() : null;
|
||
return (type, handle, reply.Data[r.Position..]);
|
||
}
|
||
}
|
||
```
|
||
|
||
Implement `MultipleServicePacket`, `SymbolObjectServices` and `TemplateObjectServices` to the byte layouts in the acceptance criteria, using `CipWriter`/`CipReader` the same way.
|
||
|
||
- [ ] **Step 4: Write and run the capture-parity tests.** If a parity test fails because libplctag sends something different from what this plan specifies (for example a different timeout-ticks byte, or different symbol attributes), **libplctag ⇄ ab_server is the parity reference for framing constants**: change the constant to match, and record the before/after in the test comment. Never change a layout the existing real-press decoder tests rely on.
|
||
- [ ] **Step 5: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests` → all pass.
|
||
- [ ] **Step 6: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix && git commit -m "feat(ab-transport): Connection Manager, MSP, Logix read/write, symbol and template service codecs"`
|
||
|
||
---
|
||
|
||
### Task 8: PCCC codec (Execute PCCC, SLC/MicroLogix typed logical, PLC-5 typed)
|
||
|
||
**Goal:** Pure builders and parsers that turn a PCCC file address into an Execute-PCCC CIP request, for both SLC/MicroLogix/LogixPccc and PLC-5. Byte parity is proven against the Task 4 captures.
|
||
|
||
**Files:**
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccFileType.cs`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccAddress.cs`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc/PcccServices.cs`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccServicesTests.cs`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc/PcccAddressTests.cs`
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] `PcccAddress(string FileLetter, int? FileNumber, int Element, int? SubElement)` is the transport-side address. It does not depend on `AbLegacyAddress`; Task 14 maps between the two.
|
||
- [ ] File type codes: `O=0x8B, I=0x8C, S=0x84, B=0x85, T=0x86, C=0x87, R=0x88, N=0x89, F=0x8A, ST=0x8D, A=0x8E, L=0x91`. Implicit file numbers: `O→0, I→1, S→2`. **Verify every code against `slc500-read-n7.hex` / `slc500-read-f8.hex`**; the capture wins if it disagrees.
|
||
- [ ] Sub-element indices: `T/C` → `PRE=1, ACC=2` (word offsets within the 3-word element, where word 0 is the control bits), and `R` → `LEN=1, POS=2`. The bit sub-elements (`EN/TT/DN/CU/CD/OV/UN`) read word 0 and are returned as `(sub=0, bit)`. `PcccAddress.ResolveSubElement` returns `(int word, int? bit)`.
|
||
- [ ] `PcccServices.ExecutePccc(requestorVendor, requestorSerial, pccc)` → `4B 02 20 67 24 01 07 <vendor u16> <serial u32> <pccc bytes>`.
|
||
- [ ] `PcccServices.SlcTypedRead(tns, addr, byteCount)` → PCCC `0F 00 <tns u16> A2 <byteCount u8> <file u8*> <type u8> <element u8*> <sub u8*>`, where `u8*` is a single byte when < 255, else `FF lo hi`.
|
||
- [ ] `PcccServices.SlcTypedWrite(tns, addr, data)` → `0F 00 <tns> AA <byteCount> <file*> <type> <element*> <sub*> <data>`.
|
||
- [ ] **Byte-count limit (review finding 12).** `byteCount` is a single byte on the wire. `SlcTypedRead`/`SlcTypedWrite` (and the PLC-5 builders) throw `AbTransportException(TooLarge)` when the requested or supplied byte count exceeds `PcccServices.MaxDataBytes = 236`; a silent `(byte)` wrap would be a corrupt write. Test 236 (ok) and 237 (throws) for both. Task 14 chunks larger arrays.
|
||
- [ ] `PcccServices.Plc5TypedRead(tns, addr, elements)` → `0F 00 <tns u16> 68 <packetOffset u16 0> <totalTrans u16 elements> <PLC-5 system address> <size u16 elements>`.
|
||
- The system address is a level-mask byte (bit0 = file level present? **derive from `plc5-read-n7.hex`**) followed by level values (`$N7:0` ⇒ data table section, file 7, element 0), each `u8*` encoded.
|
||
- `Plc5TypedWrite` is `0F 00 <tns> 67 <offset u16> <total u16> <sysaddr> <type/size param> <data>`.
|
||
- ⚠️ The PLC-5 framing must be **copied from the capture**. If ab_server's `--plc=PLC/5` path is not exercised by libplctag in the capture, 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.
|
||
- [ ] `PcccServices.ParseReply(cipReply)` strips the requestor-id echo (`len u8` + `len-1` bytes), reads `cmd u8 (0x4F), sts u8, tns u16`, then `extSts u8` if `sts == 0xF0`, then the data. Non-zero STS → `AbTransportException(AbStatusCodes.FromPccc(sts, ext))`. A TNS that differs from the request → `BadReply`.
|
||
- [ ] Capture parity: `SlcTypedRead` for `N7:0` / `F8:0` equals the PCCC payload inside the captured Execute PCCC request, with the TNS normalized; `ParseReply` on the captured reply yields the 2 / 4 data bytes.
|
||
|
||
**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~Pccc"` → pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Read the captures first.** Decode `slc500-read-n7.hex` by hand (write the decode as comments in `PcccServicesTests`) to fix the exact byte for each field. Check: is the request inside an Unconnected Send? Is it connected via Forward Open? What vendor/serial does libplctag put in the requestor id? This decode is the spec for the steps below.
|
||
- [ ] **Step 2: Write the failing tests:** golden bytes for `ExecutePccc`, `SlcTypedRead(N7:0, 2)`, `SlcTypedRead(F8:3, 4)`, `SlcTypedWrite(N7:0, [0x2A,0x00])`, an element ≥ 255 (`N7:300` ⇒ `FF 2C 01`), and the `T4:0.ACC` sub-element 2. Parse tests for success, `sts=0x10` ⇒ `Unsupported`, `sts=0xF0 ext=0x06` ⇒ `NotFound`, and a TNS mismatch ⇒ `BadReply`. Add the capture-parity facts.
|
||
- [ ] **Step 3: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter FullyQualifiedName~Pccc` → compile error.
|
||
- [ ] **Step 4: Implement.**
|
||
|
||
```csharp
|
||
// Pccc/PcccServices.cs (core)
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
|
||
|
||
public static class PcccServices
|
||
{
|
||
public static byte[] ExecutePccc(ushort vendor, uint serial, ReadOnlySpan<byte> pccc) =>
|
||
new CipWriter().U8(0x4B).U8(2).U8(0x20).U8(0x67).U8(0x24).U8(0x01)
|
||
.U8(7).U16(vendor).U32(serial).Bytes(pccc).ToArray();
|
||
|
||
private static void Field(CipWriter w, int v)
|
||
{
|
||
if (v < 0xFF) w.U8((byte)v); else w.U8(0xFF).U16((ushort)v);
|
||
}
|
||
|
||
public static byte[] SlcTypedRead(ushort tns, PcccAddress a, byte byteCount)
|
||
{
|
||
var w = new CipWriter().U8(0x0F).U8(0).U16(tns).U8(0xA2).U8(byteCount);
|
||
Field(w, a.EffectiveFileNumber); w.U8(a.FileTypeCode); Field(w, a.Element); Field(w, a.ResolveSubElement().Word);
|
||
return w.ToArray();
|
||
}
|
||
|
||
public static byte[] SlcTypedWrite(ushort tns, PcccAddress a, ReadOnlySpan<byte> data)
|
||
{
|
||
var w = new CipWriter().U8(0x0F).U8(0).U16(tns).U8(0xAA).U8((byte)data.Length);
|
||
Field(w, a.EffectiveFileNumber); w.U8(a.FileTypeCode); Field(w, a.Element); Field(w, a.ResolveSubElement().Word);
|
||
return w.Bytes(data).ToArray();
|
||
}
|
||
|
||
public static ReadOnlyMemory<byte> ParseReply(CipReply reply, ushort expectedTns)
|
||
{
|
||
if (reply.GeneralStatus != 0)
|
||
throw new AbTransportException(reply.Status, $"Execute PCCC CIP status 0x{reply.GeneralStatus:X2}.");
|
||
var span = reply.Data.Span;
|
||
var r = new CipReader(span);
|
||
var idLen = r.U8(); r.Bytes(idLen - 1);
|
||
r.U8(); // cmd | 0x40
|
||
var sts = r.U8();
|
||
var tns = r.U16();
|
||
byte? ext = sts == 0xF0 ? r.U8() : null;
|
||
if (sts != 0) throw new AbTransportException(AbStatusCodes.FromPccc(sts, ext), $"PCCC STS 0x{sts:X2}{(ext is { } e ? $" EXT 0x{e:X2}" : "")}.");
|
||
if (tns != expectedTns) throw new AbTransportException(AbStatus.BadReply, $"PCCC TNS {tns} != {expectedTns}.");
|
||
return reply.Data[r.Position..];
|
||
}
|
||
}
|
||
```
|
||
|
||
`PcccAddress` exposes `FileTypeCode` (from the table), `EffectiveFileNumber` (explicit, or the implicit number for O/I/S), and `ResolveSubElement()`. The PLC-5 builders come from the capture decode in Step 1.
|
||
|
||
- [ ] **Step 5: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter FullyQualifiedName~Pccc` → pass.
|
||
- [ ] **Step 6: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Pccc tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Pccc && git commit -m "feat(ab-transport): PCCC Execute-PCCC codec for SLC/MicroLogix typed logical and PLC-5 typed access"`
|
||
|
||
---
|
||
|
||
### Task 9: CipConnection + DeviceSessionPool (routing modes, keep-alive, press-safety bounds)
|
||
|
||
**Goal:** Own one EIP session, one CIP connection (when routed `Connected`) and **one `RequestScheduler`** per device key. Hand out ref-counted leases exposing both, keep idle class-3 connections alive, and rate-limit Forward Opens so that no failure mode can storm a PLC.
|
||
|
||
**Files:**
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/DeviceKey.cs`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ConnectionOptions.cs` (includes the `CipRouting` enum)
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/CipConnection.cs` (implements `ICipChannel`)
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/ForwardOpenThrottle.cs`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/DeviceSessionPool.cs` (includes `ConnectionLease`)
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.csproj` (`InternalsVisibleTo` for `ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests`, `ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests`, `ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests`)
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/CipConnectionTests.cs`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/ForwardOpenThrottleTests.cs`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/DeviceSessionPoolTests.cs`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipTarget.cs` (a `FakeEipServer.Handler` that answers Forward Open / Forward Close / Unconnected Send / Direct / connected data, and counts each)
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] `DeviceKey(string Host, int Port, string CipPath, string PlcAttribute)` is a record with case-insensitive `Host`/`PlcAttribute` equality.
|
||
- [ ] **`ConnectionOptions`:**
|
||
- `Routing` (`CipRouting { Connected, UnconnectedSend, Direct }`) and `AllowPacking` (bool).
|
||
- `Timeout` and `ConnectionSize` (int).
|
||
- `MinForwardOpenInterval` (default 1 s).
|
||
- `RpiMicros` (default 2 000 000) and `TimeoutMultiplier` (default 1 ⇒ ×8, so connection expiry = 16 s).
|
||
- `KeepAliveInterval` (default = RPI × 8 / 2 = 8 s).
|
||
- `VendorId` (`0x1337`) and `OriginatorSerial` (random per process).
|
||
- [ ] **Routing semantics (B3):**
|
||
- `Connected`:
|
||
- On first use: TCP connect, RegisterSession, Forward Open. When `ConnectionSize > 511` that is a Large FO (0x5B); otherwise a regular one.
|
||
- If `ShouldFallBackToRegularOpen` (T7) is true for the reply, it retries **once**, in the same throttled attempt, with a regular open at `Math.Min(ConnectionSize, 504)`.
|
||
- Requests go via `SendUnitDataAsync`. The client assigns the sequence (T5).
|
||
- `MaxRequestBytes = negotiatedSize - 2`.
|
||
- `UnconnectedSend`: requests go via SendRRData wrapped in Unconnected Send 0x52 with the route. `MaxRequestBytes = 504 - (12 + route.Length)`.
|
||
- `Direct`: requests go via SendRRData carrying the CIP request bare, even when a CIP path is configured; the path is ignored. `MaxRequestBytes = 504`.
|
||
- [ ] **Reconnect:** when the client is faulted (timeout or I/O, never caller cancellation — see T5), the next `SendAsync` reconnects through the throttle. The failing call itself throws its original exception.
|
||
- [ ] **Keep-alive (review finding 8, spec §9).** In `Connected` mode, a timer sends Get Attribute Single (`0E 03 20 01 24 01 30 01`: Identity, instance 1, attribute 1 = Vendor ID) through the connection whenever no request has been sent for `KeepAliveInterval`.
|
||
- A keep-alive failure faults the connection like any other.
|
||
- Keep-alives count towards `RequestsSent`.
|
||
- Test with fake time: no keep-alive while traffic flows; exactly one after an idle `KeepAliveInterval`.
|
||
- [ ] **No null-race (finding 16).** The open path returns the `(client, connection)` pair it established, and `SendAsync` uses that pair rather than re-reading fields after the lock.
|
||
- [ ] **Clean disposal.** Disposal never disposes a `SemaphoreSlim` that waiters may still be queued on. It sets a `_disposed` flag checked under the lock, and later callers get `AbTransportException(Abort)`.
|
||
- [ ] **`ForwardOpenThrottle`** (per `DeviceKey`; the pool keeps throttles for the process lifetime, so a reconnect after the last release is still limited):
|
||
- At most 1 open attempt in flight.
|
||
- An attempt within `MinForwardOpenInterval` of the previous **attempt start** waits for the remainder. If the caller's token fires first, it throws `AbTransportException(BadConnection, "Forward Open throttled")`.
|
||
- It uses an injected `TimeProvider`.
|
||
- [ ] **Storm test:** against a `FakeCipTarget` refusing every Forward Open, 50 concurrent `SendAsync` callers over 3 s of fake time produce **≤ 4** Forward Open frames.
|
||
- [ ] **`DeviceSessionPool`:**
|
||
- `Acquire(DeviceKey, ConnectionOptions) → ConnectionLease`. `ConnectionLease : IAsyncDisposable` exposes `ICipChannel Channel` and `RequestScheduler Scheduler`.
|
||
- **All leases for one key share one `CipConnection` and one `RequestScheduler`** (B1). The scheduler is built as `new RequestScheduler(connection, options.AllowPacking)`. A second `Acquire` with different `ConnectionOptions` for the same key reuses the first entry's options; that is logged once and pinned by a test.
|
||
- The last lease's dispose disposes the scheduler, sends Forward Close (`Connected` only) and then UnRegisterSession.
|
||
- A later acquire on the same key builds new instances.
|
||
- Thread-safe: 100 parallel acquire/dispose tasks pass.
|
||
- [ ] `DeviceSessionPool.Shared` is the process-wide instance; tests build their own.
|
||
- [ ] **Diagnostics for the gates (Tasks 18 and 21):** `internal static int ForwardOpenCount` (one increment per Forward Open frame sent, including the fallback) and `internal static int RequestsSent` (one per channel send, keep-alives included), both via `Interlocked`.
|
||
|
||
**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~CipConnection|FullyQualifiedName~ForwardOpenThrottle|FullyQualifiedName~DeviceSessionPool"` → pass; the storm test passes 20/20 consecutive runs.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Write the failing tests.**
|
||
- Use `ManualTimeProvider : TimeProvider` in `Fakes/` (about 20 lines: settable `UtcNow`, plus timers fired by `Advance(TimeSpan)`). Check `Directory.Packages.props` for `Microsoft.Extensions.TimeProvider.Testing` first; use `FakeTimeProvider` if it is already pinned, and **never add a package**.
|
||
- Write these tests:
|
||
- `Second_attempt_inside_interval_waits`
|
||
- `Attempt_after_interval_proceeds_immediately`
|
||
- `Only_one_attempt_in_flight`
|
||
- `Refusing_target_sees_at_most_one_forward_open_per_interval`
|
||
- `Large_open_service_not_supported_falls_back_to_regular_open_once`
|
||
- `Large_open_bad_size_falls_back_to_regular_open_once`
|
||
- `Connection_in_use_does_not_fall_back`
|
||
- `Direct_routing_sends_bare_cip_even_with_a_path`
|
||
- `UnconnectedSend_routing_wraps_in_0x52`
|
||
- `Connected_routing_never_sends_bare`
|
||
- `Caller_cancellation_does_not_reconnect` (cancel mid-request, then send again ⇒ `ForwardOpenCount` unchanged)
|
||
- `Timeout_reconnects_through_the_throttle`
|
||
- `Idle_connection_sends_one_keepalive_per_interval`
|
||
- `Traffic_suppresses_keepalive`
|
||
- `Leases_for_one_key_share_one_scheduler`
|
||
- `Last_lease_dispose_sends_forward_close_then_unregister`
|
||
- `Concurrent_acquire_and_release_is_safe`
|
||
- `Send_after_dispose_throws_Abort`
|
||
- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~CipConnection|FullyQualifiedName~ForwardOpenThrottle|FullyQualifiedName~DeviceSessionPool"` → compile error.
|
||
- [ ] **Step 3: Implement the throttle.**
|
||
|
||
```csharp
|
||
// Cip/ForwardOpenThrottle.cs
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||
|
||
/// <summary>Press safety: at most one Forward Open attempt in flight per device and a minimum
|
||
/// interval between attempt starts. A misconfigured client once drove ~15 Forward Opens/s at a
|
||
/// production press (ignitionoee vite-press-oee-bind.md, 2026-09-23); this is what prevents it.</summary>
|
||
public sealed class ForwardOpenThrottle(TimeSpan minInterval, TimeProvider time)
|
||
{
|
||
private readonly SemaphoreSlim _inFlight = new(1, 1);
|
||
private DateTimeOffset? _lastStart;
|
||
|
||
public async Task<T> RunAsync<T>(Func<CancellationToken, Task<T>> open, CancellationToken ct)
|
||
{
|
||
await _inFlight.WaitAsync(ct).ConfigureAwait(false);
|
||
try
|
||
{
|
||
if (_lastStart is { } last)
|
||
{
|
||
var wait = last + minInterval - time.GetUtcNow();
|
||
if (wait > TimeSpan.Zero)
|
||
{
|
||
try { await Task.Delay(wait, time, ct).ConfigureAwait(false); }
|
||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||
{ throw new AbTransportException(AbStatus.BadConnection, "Forward Open throttled; caller deadline reached."); }
|
||
}
|
||
}
|
||
_lastStart = time.GetUtcNow();
|
||
return await open(ct).ConfigureAwait(false);
|
||
}
|
||
finally { _inFlight.Release(); }
|
||
}
|
||
}
|
||
```
|
||
|
||
The throttle's semaphore is never disposed: throttles live for the process.
|
||
|
||
- [ ] **Step 4: Implement `CipConnection`.**
|
||
|
||
```csharp
|
||
// Cip/CipConnection.cs
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||
|
||
public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||
{
|
||
internal static int ForwardOpenCount;
|
||
internal static int RequestsSent;
|
||
|
||
private sealed record Open(EncapsulationClient Client, ForwardOpenReply? Conn);
|
||
|
||
private readonly DeviceKey _key;
|
||
private readonly ConnectionOptions _o;
|
||
private readonly ForwardOpenThrottle _throttle;
|
||
private readonly TimeProvider _time;
|
||
private readonly byte[] _route;
|
||
private readonly SemaphoreSlim _state = new(1, 1); // never disposed (see DisposeAsync)
|
||
private readonly ITimer? _keepAlive;
|
||
private Open? _open;
|
||
private int _negotiatedSize;
|
||
private long _lastSendTicks;
|
||
private bool _disposed;
|
||
private readonly ushort _connSerial = (ushort)Random.Shared.Next(1, ushort.MaxValue);
|
||
private readonly uint _toId = (uint)Random.Shared.Next();
|
||
|
||
public CipConnection(DeviceKey key, ConnectionOptions o, ForwardOpenThrottle throttle, TimeProvider time)
|
||
{
|
||
_key = key; _o = o; _throttle = throttle; _time = time;
|
||
_route = CipPath.EncodeRoute(key.CipPath);
|
||
if (o.Routing == CipRouting.Connected)
|
||
_keepAlive = time.CreateTimer(_ => _ = KeepAliveAsync(), null, o.KeepAliveInterval, o.KeepAliveInterval);
|
||
}
|
||
|
||
public int MaxRequestBytes => _o.Routing switch
|
||
{
|
||
CipRouting.Connected => (_negotiatedSize > 0 ? _negotiatedSize : Math.Min(_o.ConnectionSize, 504)) - 2,
|
||
CipRouting.UnconnectedSend => 504 - (12 + _route.Length),
|
||
_ => 504,
|
||
};
|
||
|
||
public async Task<ReadOnlyMemory<byte>> SendAsync(ReadOnlyMemory<byte> cip, CancellationToken ct)
|
||
{
|
||
var open = await EnsureOpenAsync(ct).ConfigureAwait(false);
|
||
Interlocked.Increment(ref RequestsSent);
|
||
Interlocked.Exchange(ref _lastSendTicks, _time.GetTimestamp());
|
||
return _o.Routing switch
|
||
{
|
||
CipRouting.Connected => await open.Client.SendUnitDataAsync(open.Conn!.OTConnectionId, cip, _o.Timeout, ct).ConfigureAwait(false),
|
||
CipRouting.UnconnectedSend => await open.Client.SendRRDataAsync(ConnectionManagerServices.UnconnectedSend(cip.Span, _route), _o.Timeout, ct).ConfigureAwait(false),
|
||
_ => await open.Client.SendRRDataAsync(cip, _o.Timeout, ct).ConfigureAwait(false),
|
||
};
|
||
}
|
||
|
||
private async Task KeepAliveAsync()
|
||
{
|
||
if (_disposed || _open is not { Client.IsConnected: true }) return;
|
||
if (_time.GetElapsedTime(Interlocked.Read(ref _lastSendTicks)) < _o.KeepAliveInterval) return;
|
||
try { await SendAsync(new byte[] { 0x0E, 0x03, 0x20, 0x01, 0x24, 0x01, 0x30, 0x01 }, CancellationToken.None).ConfigureAwait(false); }
|
||
catch (AbTransportException) { /* the client is now faulted; the next real send reconnects through the throttle */ }
|
||
}
|
||
|
||
private async Task<Open> EnsureOpenAsync(CancellationToken ct)
|
||
{
|
||
if (_open is { } o && o.Client.IsConnected) return o;
|
||
await _state.WaitAsync(ct).ConfigureAwait(false);
|
||
try
|
||
{
|
||
if (_disposed) throw new AbTransportException(AbStatus.Abort, "Connection disposed.");
|
||
if (_open is { } o2 && o2.Client.IsConnected) return o2;
|
||
return _open = await _throttle.RunAsync(OpenAsync, ct).ConfigureAwait(false);
|
||
}
|
||
finally { _state.Release(); }
|
||
}
|
||
|
||
private async Task<Open> OpenAsync(CancellationToken ct)
|
||
{
|
||
if (_open is not null) { await _open.Client.DisposeAsync().ConfigureAwait(false); _open = null; }
|
||
var client = await EncapsulationClient.ConnectAsync(_key.Host, _key.Port, _o.Timeout, ct).ConfigureAwait(false);
|
||
if (_o.Routing != CipRouting.Connected) return new Open(client, null);
|
||
try
|
||
{
|
||
var large = _o.ConnectionSize > 511;
|
||
_negotiatedSize = _o.ConnectionSize;
|
||
var reply = await ForwardOpenAsync(client, large, _negotiatedSize, ct).ConfigureAwait(false);
|
||
if (large && ConnectionManagerServices.ShouldFallBackToRegularOpen(reply))
|
||
{
|
||
_negotiatedSize = Math.Min(_o.ConnectionSize, 504);
|
||
reply = await ForwardOpenAsync(client, false, _negotiatedSize, ct).ConfigureAwait(false);
|
||
}
|
||
return new Open(client, ConnectionManagerServices.ParseForwardOpenReply(reply));
|
||
}
|
||
catch { await client.DisposeAsync().ConfigureAwait(false); throw; }
|
||
}
|
||
|
||
private async Task<CipReply> ForwardOpenAsync(EncapsulationClient client, bool large, int size, CancellationToken ct)
|
||
{
|
||
Interlocked.Increment(ref ForwardOpenCount);
|
||
var p = new ForwardOpenParams(large, _toId, _connSerial, _o.VendorId, _o.OriginatorSerial, _o.TimeoutMultiplier, _o.RpiMicros, size, _route);
|
||
return CipReply.Parse(await client.SendRRDataAsync(ConnectionManagerServices.ForwardOpen(p), _o.Timeout, ct).ConfigureAwait(false));
|
||
}
|
||
|
||
public async ValueTask DisposeAsync()
|
||
{
|
||
await _state.WaitAsync().ConfigureAwait(false);
|
||
Open? open;
|
||
try { _disposed = true; open = _open; _open = null; }
|
||
finally { _state.Release(); }
|
||
if (_keepAlive is not null) await _keepAlive.DisposeAsync().ConfigureAwait(false);
|
||
if (open is null) return;
|
||
if (open.Conn is not null && open.Client.IsConnected)
|
||
{
|
||
try
|
||
{
|
||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
|
||
await open.Client.SendRRDataAsync(ConnectionManagerServices.ForwardClose(_connSerial, _o.VendorId, _o.OriginatorSerial, _route), TimeSpan.FromSeconds(1), cts.Token).ConfigureAwait(false);
|
||
}
|
||
catch (Exception ex) when (ex is AbTransportException or OperationCanceledException) { /* PLC expires it after RPI × multiplier */ }
|
||
}
|
||
await open.Client.DisposeAsync().ConfigureAwait(false);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Implement `DeviceSessionPool` + `ConnectionLease`.**
|
||
- Keep a `Dictionary<DeviceKey, Entry>` under a `lock`, where `Entry = (CipConnection Conn, RequestScheduler Scheduler, ConnectionOptions Options, int RefCount)`.
|
||
- Keep a separate, never-pruned `Dictionary<DeviceKey, ForwardOpenThrottle>`.
|
||
- `Acquire` creates the entry on first use (`new CipConnection(key, o, throttle, time)`, `new RequestScheduler(conn, o.AllowPacking)`) and increments `RefCount`.
|
||
- `ConnectionLease.DisposeAsync` is idempotent (`Interlocked` flag). It decrements the count and, at zero, removes the entry; **outside the lock** it awaits `Scheduler.DisposeAsync()`, then `Conn.DisposeAsync()`.
|
||
- The pool constructor takes an optional `TimeProvider` (default `TimeProvider.System`).
|
||
- [ ] **Step 6: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests` → all pass, then run `Refusing_target_sees_at_most_one_forward_open_per_interval` 20× to check it is not flaky.
|
||
- [ ] **Step 7: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests && git commit -m "feat(ab-transport): per-device connection with routing modes, shared scheduler, keep-alive and Forward Open throttle"`
|
||
|
||
---
|
||
|
||
### Task 10: RequestScheduler — MSP coalescing + fragmentation
|
||
|
||
**Goal:** A per-channel scheduler that coalesces concurrent CIP requests into Multiple Service Packets within `MaxRequestBytes`, keeps one request in flight, fans each reply back to its own waiter, and runs fragmented read/write loops for payloads that do not fit in one frame.
|
||
|
||
**Files:**
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Cip/RequestScheduler.cs`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport/Logix/LogixTagClient.cs` (read/write helpers over the scheduler, including the fragment loops)
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Cip/RequestSchedulerTests.cs`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Logix/LogixTagClientTests.cs`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/Fakes/FakeCipChannel.cs`
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] `RequestScheduler(ICipChannel channel, bool allowPacking)` exposes `Task<CipReply> SendAsync(byte[] request, CancellationToken ct)`.
|
||
- [ ] **Packing:** while one request is in flight, new requests queue. When the channel frees, the scheduler takes queued requests in FIFO order while `MultipleServicePacket.BuildSize(sizes) <= channel.MaxRequestBytes`. More than one taken ⇒ one MSP. Exactly one ⇒ sent bare. `allowPacking == false` ⇒ always bare.
|
||
- [ ] **Boundary tests:** with `MaxRequestBytes = M`, two requests whose MSP size is exactly `M` pack into one frame, and two whose MSP size is `M + 1` go as two frames.
|
||
- [ ] **A single request larger than `MaxRequestBytes`** is sent bare. Callers must fragment; `LogixTagClient` does.
|
||
- [ ] **Reply fan-out:** each waiter gets its own embedded `CipReply`, with its own general status, so one failing service in an MSP fails only its own waiter. If the MSP envelope itself fails (non-zero general status other than `0x1E` embedded-service-error), or the channel throws, every waiter in that batch faults with the same `AbTransportException`.
|
||
- [ ] **Cancellation:**
|
||
- A waiter cancelled while queued is removed and never sent.
|
||
- A waiter cancelled while its batch is in flight gets `OperationCanceledException`, and the batch's other waiters still complete.
|
||
- **The channel call always uses the scheduler's own `_stop` token, never a waiter's**, for single sends as well as MSPs (B2). So one caller's cancellation can never abort, or fault, a shared exchange.
|
||
- Each waiter's `CancellationTokenRegistration` is stored on its `Pending` record and **disposed when the TCS completes** (review finding 10). A test makes 10 000 sends on one long-lived token and asserts no growth: measure with a counting `CancellationTokenSource` subclass, or assert `Pending` registrations are disposed via a test hook.
|
||
- [ ] **Owner:** one scheduler per device connection, created by `DeviceSessionPool` (T9). This task builds only the class and tests it over `FakeCipChannel`.
|
||
- [ ] **`LogixTagClient`:**
|
||
- `ReadAsync(path, elements)` sends Read Tag.
|
||
- If the reply status is `0x06` (Partial), it **keeps** that reply's data and continues with Read Tag Fragmented at `offset = bytesReceivedSoFar`, appending each reply's data until a reply has status `0x00`. That avoids double-counting the first chunk (review finding 15).
|
||
- It returns `(TypeCode, StructHandle, byte[] Data)`. The type code and handle come from the first reply; fragment replies repeat the type header, so strip it from each before appending.
|
||
- **Reply-size note:** packing bounds only the *request* size, so many large reads packed together can produce a reply that the controller truncates with `0x06` per service. That is correct behaviour (the fragment loop recovers it), not an error.
|
||
- `WriteAsync(path, type, handle, elements, data)` sends one Write Tag when it fits, otherwise Write Tag Fragmented chunks of `MaxRequestBytes - 12 - path.Length`, aligned down to the element width.
|
||
- Non-zero final status ⇒ `AbTransportException(reply.Status)`.
|
||
- [ ] `FakeCipChannel` lets a test set `MaxRequestBytes`, records every request, and answers via a delegate. It is reused by Tasks 12 and 13.
|
||
|
||
**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~RequestScheduler|FullyQualifiedName~LogixTagClient"` → pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Write `FakeCipChannel` and the failing tests.**
|
||
|
||
```csharp
|
||
// Fakes/FakeCipChannel.cs
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
||
|
||
internal sealed class FakeCipChannel(int maxRequestBytes = 504) : ICipChannel
|
||
{
|
||
public int MaxRequestBytes { get; set; } = maxRequestBytes;
|
||
public List<byte[]> Sent { get; } = [];
|
||
public Func<byte[], Task<byte[]>> Respond { get; set; } = r => Task.FromResult(new byte[] { (byte)(r[0] | 0x80), 0, 0, 0 });
|
||
public TaskCompletionSource? Gate { get; set; }
|
||
|
||
public async Task<ReadOnlyMemory<byte>> SendAsync(ReadOnlyMemory<byte> req, CancellationToken ct)
|
||
{
|
||
lock (Sent) Sent.Add(req.ToArray());
|
||
if (Gate is { } g) await g.Task.WaitAsync(ct);
|
||
return await Respond(req.ToArray());
|
||
}
|
||
}
|
||
```
|
||
|
||
Tests to write (each with explicit byte sizes):
|
||
- `Requests_queued_behind_an_inflight_one_are_packed_into_one_msp`: hold `Gate` with the first request in flight, enqueue 3 more, release. Assert `Sent.Count == 2` and `Sent[1][0] == 0x0A`.
|
||
- `Msp_exactly_at_limit_packs` / `Msp_one_byte_over_limit_splits`.
|
||
- `Packing_disabled_sends_each_bare`.
|
||
- `Per_service_error_fails_only_its_waiter`: the MSP reply carries replies with status 0 and status 0x04. Waiter 1 gets Ok, waiter 2 gets a `CipReply` with `GeneralStatus == 0x04`.
|
||
- `Channel_failure_faults_every_waiter_in_the_batch`.
|
||
- `Queued_waiter_cancelled_before_send_is_never_sent`.
|
||
- `Fragmented_read_keeps_the_first_partial_and_continues`: the first (Read Tag) reply has status 0x06 with 100 data bytes, and the next two are Read Tag Fragmented replies (0x06 with 100 bytes, then 0x00 with 40). The result is 240 bytes, and the fragmented requests were sent at offsets **100 and 200**.
|
||
- `Waiter_cancellation_never_reaches_the_channel_token`: the fake channel records the token it received; assert it is never a waiter's token.
|
||
- `Cancellation_registrations_are_released`.
|
||
- `Fragmented_write_chunks_align_to_element_width`.
|
||
|
||
- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter "FullyQualifiedName~RequestScheduler|FullyQualifiedName~LogixTagClient"` → compile error.
|
||
|
||
- [ ] **Step 3: Implement the scheduler.**
|
||
|
||
```csharp
|
||
// Cip/RequestScheduler.cs
|
||
using System.Threading.Channels;
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||
|
||
public sealed class RequestScheduler : IAsyncDisposable
|
||
{
|
||
private sealed class Pending(byte[] request)
|
||
{
|
||
public byte[] Request { get; } = request;
|
||
public TaskCompletionSource<CipReply> Tcs { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||
public CancellationTokenRegistration Registration { get; set; }
|
||
}
|
||
|
||
private readonly ICipChannel _channel;
|
||
private readonly bool _allowPacking;
|
||
private readonly LinkedList<Pending> _queue = new();
|
||
private readonly object _lock = new();
|
||
private readonly SemaphoreSlim _signal = new(0);
|
||
private readonly CancellationTokenSource _stop = new();
|
||
private readonly Task _pump;
|
||
|
||
public RequestScheduler(ICipChannel channel, bool allowPacking)
|
||
{ _channel = channel; _allowPacking = allowPacking; _pump = Task.Run(PumpAsync); }
|
||
|
||
public Task<CipReply> SendAsync(byte[] request, CancellationToken ct)
|
||
{
|
||
var p = new Pending(request);
|
||
LinkedListNode<Pending> node;
|
||
lock (_lock) node = _queue.AddLast(p);
|
||
if (ct.CanBeCanceled)
|
||
{
|
||
p.Registration = ct.Register(() =>
|
||
{
|
||
lock (_lock) { if (node.List is not null) _queue.Remove(node); }
|
||
p.Tcs.TrySetCanceled(ct);
|
||
});
|
||
// Release the registration as soon as the request completes, however it completes (finding 10).
|
||
_ = p.Tcs.Task.ContinueWith(static (_, state) => ((Pending)state!).Registration.Dispose(), p,
|
||
CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
|
||
}
|
||
_signal.Release();
|
||
return p.Tcs.Task;
|
||
}
|
||
|
||
private List<Pending> TakeBatch()
|
||
{
|
||
var batch = new List<Pending>();
|
||
lock (_lock)
|
||
{
|
||
var sizes = new List<int>();
|
||
while (_queue.First is { } n)
|
||
{
|
||
if (batch.Count > 0 && !_allowPacking) break;
|
||
sizes.Add(n.Value.Request.Length);
|
||
if (batch.Count > 0 && MultipleServicePacket.BuildSize(sizes) > _channel.MaxRequestBytes) break;
|
||
batch.Add(n.Value);
|
||
_queue.RemoveFirst();
|
||
}
|
||
}
|
||
return batch;
|
||
}
|
||
|
||
private async Task PumpAsync()
|
||
{
|
||
while (!_stop.IsCancellationRequested)
|
||
{
|
||
try { await _signal.WaitAsync(_stop.Token).ConfigureAwait(false); } catch (OperationCanceledException) { return; }
|
||
var batch = TakeBatch();
|
||
if (batch.Count == 0) continue;
|
||
try
|
||
{
|
||
if (batch.Count == 1)
|
||
{
|
||
var raw = await _channel.SendAsync(batch[0].Request, _stop.Token).ConfigureAwait(false); // never a waiter's token (B2)
|
||
batch[0].Tcs.TrySetResult(CipReply.Parse(raw));
|
||
}
|
||
else
|
||
{
|
||
var raw = await _channel.SendAsync(MultipleServicePacket.Build(batch.Select(b => b.Request).ToList()), _stop.Token).ConfigureAwait(false);
|
||
var envelope = CipReply.Parse(raw);
|
||
if (envelope.GeneralStatus is not (0x00 or 0x1E))
|
||
throw new AbTransportException(envelope.Status, $"MSP failed: 0x{envelope.GeneralStatus:X2}.");
|
||
var replies = MultipleServicePacket.Parse(envelope, batch.Count);
|
||
for (var i = 0; i < batch.Count; i++) batch[i].Tcs.TrySetResult(replies[i]);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
foreach (var b in batch) b.Tcs.TrySetException(ex);
|
||
}
|
||
// drain remaining signals for items taken in this batch
|
||
for (var i = 1; i < batch.Count; i++) _signal.Wait(0);
|
||
}
|
||
}
|
||
|
||
public async ValueTask DisposeAsync()
|
||
{
|
||
_stop.Cancel();
|
||
try { await _pump.ConfigureAwait(false); } catch { /* stopping */ }
|
||
lock (_lock) foreach (var p in _queue) p.Tcs.TrySetCanceled();
|
||
_signal.Dispose(); _stop.Dispose();
|
||
}
|
||
}
|
||
```
|
||
|
||
> The requirement "requests queued **while one is in flight** are packed" falls out of the single pump: the pump is busy awaiting the channel, so requests accumulate until it loops back. Do not add a delay-based batching window.
|
||
|
||
- [ ] **Step 4: Implement `LogixTagClient`** to the acceptance criteria. It holds a `RequestScheduler` and an `ICipChannel` (for `MaxRequestBytes`), and builds requests with `LogixServices`.
|
||
- [ ] **Step 5: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests` → all pass, then run the RequestScheduler tests 20× to check they are not flaky.
|
||
- [ ] **Step 6: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests && git commit -m "feat(ab-transport): MSP-coalescing request scheduler and fragmented Logix read/write"`
|
||
|
||
---
|
||
|
||
### Task 11: libplctag-confinement guard
|
||
|
||
**Goal:** A source-scan test that fails if any production `.cs` file outside the libplctag adapters references `libplctag`, so the eventual deletion stays mechanical.
|
||
|
||
**Files:**
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/LibplctagConfinementGuardTests.cs`
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] The test walks up from `AppContext.BaseDirectory` to the directory containing `ZB.MOM.WW.OtOpcUa.slnx`, then scans every `*.cs` under `src/` (excluding `bin`/`obj`) for **code** references: `using libplctag` or `libplctag.(Tag|Status|PlcType|Protocol|LibPlcTagException|NativeImport)`. Prose such as the CLI help string `"via libplctag. Second of six…"` (`AbCip.Cli/Program.cs:9`, `AbLegacy.Cli/Program.cs:8`) must **not** match, or the guard is red from wave 2 until T15/T16 (review finding 6).
|
||
- [ ] Allowed files: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Libplctag*.cs` and `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/Libplctag*.cs` only. Comments and XML docs are ignored by stripping `//` line comments before matching.
|
||
- [ ] The failure message lists each offending file and line.
|
||
- [ ] A second fact, `Prose_mentions_of_libplctag_do_not_trip_the_guard`, runs the regex against the literal `"…via libplctag. Second of six driver CLIs"` and asserts no match.
|
||
- [ ] **Falsifiability check:** temporarily add `using libplctag;` to `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs`, run the test and see it go red, then revert. Record that you did this in the commit message.
|
||
|
||
**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests --filter LibplctagConfinementGuard` → pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Write the test.**
|
||
|
||
```csharp
|
||
using System.Text.RegularExpressions;
|
||
using Shouldly;
|
||
using Xunit;
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests;
|
||
|
||
public sealed class LibplctagConfinementGuardTests
|
||
{
|
||
private static readonly Regex Reference = new(
|
||
@"\busing\s+libplctag\b|\blibplctag\.(Tag|Status|PlcType|Protocol|LibPlcTagException|NativeImport)\b",
|
||
RegexOptions.Compiled);
|
||
|
||
[Fact]
|
||
public void Only_the_libplctag_adapters_reference_libplctag()
|
||
{
|
||
var root = new DirectoryInfo(AppContext.BaseDirectory);
|
||
while (root is not null && !File.Exists(Path.Combine(root.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) root = root.Parent;
|
||
root.ShouldNotBeNull("could not find the repo root");
|
||
|
||
var offenders = new List<string>();
|
||
foreach (var file in Directory.EnumerateFiles(Path.Combine(root.FullName, "src"), "*.cs", SearchOption.AllDirectories))
|
||
{
|
||
if (file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}") ||
|
||
file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")) continue;
|
||
var name = Path.GetFileName(file);
|
||
var dir = Path.GetFileName(Path.GetDirectoryName(file)!);
|
||
var allowed = name.StartsWith("Libplctag", StringComparison.Ordinal)
|
||
&& dir is "ZB.MOM.WW.OtOpcUa.Driver.AbCip" or "ZB.MOM.WW.OtOpcUa.Driver.AbLegacy";
|
||
if (allowed) continue;
|
||
var lines = File.ReadAllLines(file);
|
||
for (var i = 0; i < lines.Length; i++)
|
||
{
|
||
var code = lines[i].Split("//")[0];
|
||
if (Reference.IsMatch(code)) offenders.Add($"{Path.GetRelativePath(root.FullName, file)}:{i + 1}: {lines[i].Trim()}");
|
||
}
|
||
}
|
||
offenders.ShouldBeEmpty(string.Join(Environment.NewLine, offenders));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run it** (it should pass now that T2 and T3 have merged). Then do the falsifiability check from the acceptance criteria.
|
||
- [ ] **Step 3: Commit.** `git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests/LibplctagConfinementGuardTests.cs && git commit -m "test(ab-transport): guard libplctag confinement to its adapters (falsified by adding using libplctag to AbCipDriver.cs)"`
|
||
|
||
---
|
||
|
||
### Task 12: `NativeAbCipTagRuntime` + factory
|
||
|
||
**Goal:** A native `IAbCipTagRuntime` that leases the device connection from `DeviceSessionPool.Shared`, reads and writes through `LogixTagClient`, decodes the managed buffer with the same semantics as `LibplctagTagRuntime`, and adds a CIP type check.
|
||
|
||
**Files:**
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagFactory.cs`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipDevice.cs` (maps `AbCipTagCreateParams` → `DeviceKey` + `ConnectionOptions`; shared with Task 13)
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs` (add trailing optionals `AbCipDataType? AuthoredType = null, bool UseConnectedMessaging = true` to `AbCipTagCreateParams`)
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs` (pass `UseConnectedMessaging: profile.SupportsConnectedMessaging`)
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs` (`EnsureTagRuntimeAsync` passes `AuthoredType: def.DataType`; `DeviceState`'s create-params builder passes `UseConnectedMessaging: Profile.SupportsConnectedMessaging`)
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs`
|
||
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.csproj` (add a `ProjectReference` to Ab.Transport and `<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests\Fakes\FakeCipChannel.cs" Link="Fakes\FakeCipChannel.cs" />`.; source-linking compiles the fake into this assembly, so it stays `internal`)
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] `NativeAbCipDevice.Key(p)` = `new DeviceKey(p.Gateway, p.Port > 0 ? p.Port : 44818, p.CipPath, p.LibplctagPlcAttribute)`.
|
||
- [ ] `NativeAbCipDevice.Options(p)`:
|
||
- `Timeout = p.Timeout`, `ConnectionSize = p.ConnectionSize`, `AllowPacking = p.AllowPacking`.
|
||
- **`Routing`:** if `p.UseConnectedMessaging` is false, use `UnconnectedSend` when `p.CipPath` is non-empty and `Direct` when it is empty. Otherwise use `Connected`.
|
||
- `UseConnectedMessaging` is a new trailing optional `bool UseConnectedMessaging = true` on `AbCipTagCreateParams`. It is set at every construction site from `AbCipPlcFamilyProfile.SupportsConnectedMessaging`, so the family profile stays the single authority and string-matching `"micro800"` is not needed (review finding 18). The sites are `AbCipDriver` `DeviceState` (around `AbCipDriver.cs:1690-1700`, where `AllowPacking`/`ConnectionSize` are already merged) and `AbCipDriverProbe`; find any others with `grep -rn "new AbCipTagCreateParams" src`.
|
||
- Cross-check the result against the Task 4 "Routing observed" table for controllogix and micro800, and record any mismatch in the test comment.
|
||
- [ ] **Initialization and reads:**
|
||
- `InitializeAsync` acquires the lease from `DeviceSessionPool.Shared` and builds a per-runtime `LogixTagClient` over **the lease's shared `Scheduler` and `Channel`**. It must never construct its own `RequestScheduler`: that would defeat MSP coalescing across tags (B1). It encodes the path once, splitting off a trailing bit selector with `CipPath.SplitBitSelector`; that is stored as the runtime's implicit bit.
|
||
- It performs **one read** to prove the tag exists. That matches libplctag, whose `InitializeAsync` errors for an unknown tag; the driver's 05/STAB-8 logic depends on create throwing `NotFound`.
|
||
- `ReadAsync` reads `p.ElementCount` elements, stores the data, `TypeCode` and `StructHandle`, and sets status `Ok`. On `AbTransportException` it stores the exception's status **and rethrows**, matching libplctag's exception-on-failure behaviour. `GetSize()` returns the data length.
|
||
- [ ] **Decode** is byte-identical to `LibplctagTagRuntime` for every `AbCipDataType` (Bool/SInt/USInt/Int/UInt/DInt/UDInt/LInt/ULInt/Real/LReal/String/Dt). Specifically:
|
||
- Bool with a bit index ⇒ bit `n` of the little-endian buffer.
|
||
- Bool without ⇒ `byte[0] != 0`.
|
||
- String ⇒ Logix STRING `u32 LEN` + `DATA`, ASCII, `LEN` clamped to the data length − 4.
|
||
- `DecodeArray` uses stride = `data.Length / count` when it divides evenly, else the static width (the same `ResolveArrayStride` rule; call `LibplctagTagRuntime.ResolveArrayStride` with `elementSize: null` to share it).
|
||
- Bool arrays are bit-packed (bit `i` of the buffer).
|
||
- [ ] **Type check:** after a read, if `TypeCode` is atomic and differs from the authored type's Logix code, `GetStatus()` returns `(int)AbStatus.TypeMismatch` and `Decode*` returns null. The mapping is `AbCipDataType` → `LogixTypeCode`, with `Dt → DInt`; `String`/`Structure` are exempt. Exemptions:
|
||
- (a) A BOOL read with a bit index against a DINT host is **not** a mismatch.
|
||
- (b) **Authored `Bool` with returned `0xD3`** (the Logix packed BOOL-array type) is **not** a mismatch (review finding 11).
|
||
- Tests cover both exemptions, plus a positive mismatch.
|
||
- [ ] **Encode/write:**
|
||
- `EncodeValue` fills a managed write buffer with the same encodings as `LibplctagTagRuntime.EncodeValue`, including the same `NotSupportedException` cases.
|
||
- `WriteAsync` writes with type = the last read `TypeCode` if known, else the authored type's code.
|
||
- For String, it uses `Struct` + the last `StructHandle`. If none is known it performs a read first; that read happens inside `WriteAsync`, under the driver's existing per-runtime lock.
|
||
- The STRING write payload is `u32 len` + DATA padded to the capacity learned from the last read's data length − 4.
|
||
- [ ] `Dispose` releases the lease, waiting for at most 2 s (`lease.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(2))`). It must not fire and forget: an immediate re-init would otherwise open a second session to the press while the old Forward Close is still in flight (review finding 22). It never disposes the shared scheduler; the pool does that on the last release.
|
||
- [ ] `NativeAbCipTagFactory : IAbCipTagFactory` creates `NativeAbCipTagRuntime`.
|
||
- [ ] Test seam: `internal static NativeAbCipTagRuntime ForTest(ICipChannel channel, AbCipTagCreateParams p)` builds a runtime over the given channel with a private `RequestScheduler(channel, p.AllowPacking)` (test-only; no pool lease). Production code always goes through the factory and `DeviceSessionPool.Shared`.
|
||
- [ ] **Unit tests** (against `FakeCipChannel` via `ForTest`):
|
||
- decode parity for each type, using a buffer captured from `LibplctagTagRuntime` semantics (hard-coded bytes);
|
||
- the TypeMismatch case (REAL returned for a DInt-authored tag);
|
||
- an unknown tag ⇒ `InitializeAsync` throws `AbTransportException(NotFound)`;
|
||
- a STRING write sends `Struct` + the handle learned from the prior read;
|
||
- a 1-element array is read as an array (`IsArray`).
|
||
|
||
**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter NativeAbCipTagRuntime` → pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Write the failing tests**, for example:
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task Real_returned_for_a_DInt_authored_tag_is_TypeMismatch()
|
||
{
|
||
var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("CC000000" + "CA00" + "0000803F")) };
|
||
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Speed", AbCipDataType.DInt));
|
||
await rt.InitializeAsync(CancellationToken.None);
|
||
await rt.ReadAsync(CancellationToken.None);
|
||
rt.GetStatus().ShouldBe((int)AbStatus.TypeMismatch);
|
||
rt.DecodeValue(AbCipDataType.DInt, null).ShouldBeNull();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Unknown_tag_fails_initialize_with_NotFound()
|
||
{
|
||
var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("CC000400")) };
|
||
using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Nope", AbCipDataType.DInt));
|
||
(await Should.ThrowAsync<AbTransportException>(() => rt.InitializeAsync(CancellationToken.None))).Status.ShouldBe(AbStatus.NotFound);
|
||
}
|
||
```
|
||
|
||
`AbCipTagCreateParams` has no authored-type field. Add an optional `AbCipDataType? AuthoredType = null` parameter at the **end** of the record (it is binary compatible for every existing named-argument caller). Set it at `AbCipDriver.cs` `EnsureTagRuntimeAsync` from `def.DataType`. The libplctag runtime ignores it.
|
||
|
||
- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter NativeAbCipTagRuntime` → compile error.
|
||
- [ ] **Step 3: Implement** to the acceptance criteria. Use `BinaryPrimitives` for every decode, never `BitConverter`.
|
||
- [ ] **Step 4: Run.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests` → all pass, including every pre-existing test.
|
||
- [ ] **Step 5: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests && git commit -m "feat(abcip): native tag runtime over the managed CIP transport, with returned-type checking"`
|
||
|
||
---
|
||
|
||
### Task 13: Native AbCip symbol enumerator + template reader
|
||
|
||
**Goal:** Native `IAbCipTagEnumerator` and `IAbCipTemplateReader` implementations whose output buffers feed the **unchanged** decoders.
|
||
|
||
**Files:**
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/SymbolListingEnumerator.cs` (the transport-neutral listing loop moved out of `LibplctagTagEnumerator.cs`)
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagEnumerator.cs` (keeps only `LibplctagListingReader` + `LibplctagTagEnumeratorFactory`)
|
||
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/` files referencing `LibplctagTagEnumerator` (rename to `SymbolListingEnumerator`)
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagEnumerator.cs`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTemplateReader.cs`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagEnumeratorTests.cs`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTemplateReaderTests.cs`
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] **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)`.
|
||
- [ ] **Native listing reader.** `NativeAbCipTagEnumerator` = `new SymbolListingEnumerator(NativeListingReader.ReadAsync)`, wrapped in a class for the factory. Its reader:
|
||
- `"@tags"` ⇒ pages `SymbolObjectServices.GetInstanceAttributeList(null, start)` from instance 0, appending entry bytes. While the reply is `More`, it continues from `LastInstance + 1`.
|
||
- `"Program:X.@tags"` ⇒ the same with `programName: "X"`.
|
||
- It returns the concatenated bytes.
|
||
- [ ] `NativeAbCipTemplateReader.ReadAsync(params, id)`:
|
||
- `GetAttributes(id)` gives `defWords`, `structBytes`, `members`, `handle`.
|
||
- It then sends `ReadTemplate(id, offset, chunk)` repeatedly, with `chunk = Math.Min(remaining, MaxRequestBytes - 16)`, where the total = `defWords * 4 - 23` (the CIP-documented Read Template length), advancing by each reply's data length until the status is 0. Status `0x06` continues.
|
||
- It returns `TemplateObjectServices.BuildDecoderBuffer(...)`.
|
||
- [ ] Unit tests feed the **real controller captures** through the native path. The fake channel serves, as reply data, the bytes contained in `CipTemplateObjectWireCaptureTests` (rev 34.11 press). The test asserts that `CipTemplateObjectDecoder.Decode(nativeBuffer)` equals `Decode(capturedLibplctagBuffer)`, member by member.
|
||
- The captured libplctag buffer's header **is** the 14-byte header, so the fake replies are built from that header's fields plus the raw bytes after byte 14.
|
||
- Do the same for `CipSymbolObjectRealControllerCaptureTests` entries: split them across two pages with a `0x06` status between, and assert the decoded tag list is identical.
|
||
- [ ] Both factories (`NativeAbCipTagEnumeratorFactory`, `NativeAbCipTemplateReaderFactory`) exist and lease from `DeviceSessionPool.Shared` via `NativeAbCipDevice`. They send through **the lease's shared `Scheduler`**, never a private one, and dispose the lease (bounded 2 s wait, as in T12) when the enumeration or read completes.
|
||
|
||
**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter "FullyQualifiedName~NativeAbCipTagEnumerator|FullyQualifiedName~NativeAbCipTemplateReader|FullyQualifiedName~Discovery"` → pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Write the failing tests** that reuse the real-capture byte arrays. Reference them through the existing test classes' fields if those are accessible; otherwise copy the hex into a shared `RealControllerCaptures.cs` and point both old and new tests at it.
|
||
- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter "FullyQualifiedName~NativeAbCipTagEnumerator|FullyQualifiedName~NativeAbCipTemplateReader"` → compile error.
|
||
- [ ] **Step 3: Split the enumerator.** Move the class body into `SymbolListingEnumerator.cs` as `SymbolListingEnumerator`, leaving `LibplctagListingReader.ReadAsync` and `LibplctagTagEnumeratorFactory` (which now returns `new SymbolListingEnumerator(LibplctagListingReader.ReadAsync)`) in `LibplctagTagEnumerator.cs`. Then implement the native readers.
|
||
- [ ] **Step 4: Run** the full AbCip.Tests suite → all pass.
|
||
- [ ] **Step 5: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests && git commit -m "feat(abcip): native symbol listing and template reads feeding the unchanged decoders"`
|
||
|
||
---
|
||
|
||
### Task 14: `NativeAbLegacyTagRuntime` + factory
|
||
|
||
**Goal:** A native `IAbLegacyTagRuntime` over PCCC (Task 8) carried in Execute PCCC through a `DeviceSessionPool` lease.
|
||
|
||
**Files:**
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs`
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagFactory.cs`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/NativeAbLegacyTagRuntimeTests.cs`
|
||
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests.csproj` (reference Ab.Transport + link `FakeCipChannel.cs`, as in Task 12)
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] **Connection:**
|
||
- `DeviceKey` is built from `AbLegacyTagCreateParams` the same way as Task 12 (default port 44818).
|
||
- **`Routing` comes from the Task 4 "Routing observed" table (B3), per family:**
|
||
- Expected: `slc500`/`micrologix`/`plc5` ⇒ **`Direct`** (libplctag sends Execute PCCC bare in SendRRData even though the driver forces `EffectiveCipPath = "1,0"`, `AbLegacyDriver.cs:864`); `logixpccc` ⇒ **`Connected`**.
|
||
- **If the capture shows otherwise, the capture wins.** Encode the choice in one `static CipRouting RoutingFor(string plcAttribute)` with a comment citing the capture file, and pin it with a test per family.
|
||
- `ConnectionSize = 504`, `AllowPacking = false` (PCCC requests are never MSP-packed).
|
||
- Send through the lease's shared `Scheduler`, never a private one (B1).
|
||
- [ ] **Address handling:**
|
||
- `AbLegacyAddress.TryParse(p.TagName)` ⇒ `PcccAddress(FileLetter, FileNumber, WordNumber, sub)`. The sub-element comes from `ResolveSubElement`. A `BitIndex` is kept for decode only, because reads are word reads.
|
||
- Byte counts per element: N/A/B/I/O/S = 2, F/L = 4, T/C/R = 6 when no sub-element (the whole element), 2 when a word sub-element is chosen, ST = 84.
|
||
- Element count = `p.ElementCount`.
|
||
- [ ] **Read and write:**
|
||
- `ReadAsync` dispatches by `plc` attribute: `slc500`/`micrologix`/`logixpccc` ⇒ `SlcTypedRead`; `plc5` ⇒ `Plc5TypedRead`, or `AbTransportException(Unsupported)` when `PcccServices.Plc5Verified` is false (Task 8).
|
||
- The TNS increments per request.
|
||
- **Chunking (review finding 12):** a read or write whose byte count exceeds `PcccServices.MaxDataBytes` (236) is split into consecutive element ranges of at most 236 bytes (118 words, or 59 longs/floats). The element number advances per chunk and the data is concatenated in order. A failure in any chunk fails the whole operation with that chunk's status; a partial write is reported as failed, never as Good. Test a 256-word array read (three chunks: 118 + 118 + 20) and a 130-word write (two chunks).
|
||
- On failure it stores the status and rethrows.
|
||
- `InitializeAsync` performs one read, for the same `NotFound`-on-create reason as Task 12.
|
||
- [ ] **Decode** is identical to `LibplctagLegacyTagRuntime`:
|
||
- Bit with index ⇒ bit of the LE word (`/N` up to 15, or up to 31 for L).
|
||
- Bit without ⇒ `(word & 1) != 0`.
|
||
- Int/AnalogInt ⇒ `short`→`int`; Long ⇒ `int`; Float ⇒ `float`.
|
||
- String ⇒ the SLC ST layout: `u16 LEN` + 82 data bytes **with each byte pair swapped**. ⚠️ Verify this byte order against `slc500` ST data in ab_server. If ab_server does not seed ST, test against a hand-built buffer and flag it in Task 20 docs as unverified.
|
||
- Timer/Counter/Control element without a sub-element ⇒ `GetInt32(0)` parity (the first 4 bytes, LE).
|
||
- `DecodeArray` uses the offsets documented in `LibplctagLegacyTagRuntime.DecodeArray`.
|
||
- [ ] **Encode** follows `LibplctagLegacyTagRuntime.EncodeValue` case by case, with the same `NotSupportedException` for bit-with-index. `WriteAsync` ⇒ `SlcTypedWrite` (or PLC-5 / Unsupported).
|
||
- [ ] **Unit tests:**
|
||
- N7:0 read decode;
|
||
- F8 read;
|
||
- B3:0/5 decode;
|
||
- an N7 array of 5;
|
||
- T4:0.ACC sub-element addressing (bytes on the wire);
|
||
- STS 0x20 ⇒ `NotFound` on initialize;
|
||
- a write of N7:0 = 42 produces the expected PCCC bytes.
|
||
|
||
**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter NativeAbLegacyTagRuntime` → pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Write the failing tests** against `FakeCipChannel`. Build replies with this helper:
|
||
|
||
```csharp
|
||
private static byte[] PcccReply(ushort tns, byte[] data) =>
|
||
[0xCB, 0x00, 0x00, 0x00, 0x07, 0x37, 0x13, 0x00, 0x00, 0x00, 0x00, 0x4F, 0x00, (byte)tns, (byte)(tns >> 8), .. data];
|
||
```
|
||
|
||
Note the leading `0xCB` (0x4B | 0x80) and the 7-byte requestor-id echo. Adjust it to whatever echo the Task 4 capture shows.
|
||
- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter NativeAbLegacyTagRuntime` → compile error.
|
||
- [ ] **Step 3: Implement** to the acceptance criteria.
|
||
- [ ] **Step 4: Run** the full AbLegacy.Tests suite → all pass.
|
||
- [ ] **Step 5: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests && git commit -m "feat(ablegacy): native PCCC tag runtime over the managed transport"`
|
||
|
||
---
|
||
|
||
### Task 15: AbCip `Transport` switch (options, factory, probe, CLI)
|
||
|
||
**Goal:** One `Transport` option selects the native or libplctag factory set everywhere AbCip creates a transport object: the driver, Test Connect, and the CLI.
|
||
|
||
**Files:**
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipTransport.cs`
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs` (add `public AbCipTransport Transport { get; init; } = AbCipTransport.Libplctag;`)
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs` (DTO `string? Transport`; `ParseEnum<AbCipTransport>(dto.Transport, …, fallback: AbCipTransport.Libplctag)`)
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipTransportFactories.cs`
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs:145-155` (null factories ⇒ `AbCipTransportFactories.For(options.Transport)`)
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs` (use `AbCipTransportFactories.For(opts.Transport).Tags`)
|
||
- Modify: `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/` (`--transport native|libplctag`, default `libplctag`; help text "via libplctag" → "via libplctag or the native transport")
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs`
|
||
- Modify: `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests/` (add a `--transport` parse test)
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] `public enum AbCipTransport { Libplctag, Native }`.
|
||
- [ ] `AbCipTransportFactories.For(t)` returns `(IAbCipTagFactory Tags, IAbCipTagEnumeratorFactory Enumerators, IAbCipTemplateReaderFactory Templates)`: the native trio for `Native`, the libplctag trio for `Libplctag`.
|
||
- [ ] Explicitly injected factories (tests) still win over the option.
|
||
- [ ] JSON configs: `"transport": "Native"` ⇒ Native; `"transport": "native"` ⇒ Native (case-insensitive, as with the existing `ParseEnum`); absent ⇒ Libplctag; `"transport": "Bogus"` ⇒ the same failure the existing `ParseEnum` raises for a bad `PlcFamily` (do not invent a new behaviour).
|
||
- [ ] **Reinit does NOT swap transports, by design (review finding 19).** `AbCipDriver.ReinitializeAsync` (`AbCipDriver.cs:401`) is `ShutdownAsync` + `InitializeAsync` over constructor-held factories, so a transport change in the reinit JSON is inert. Do **not** invent factory swapping:
|
||
- Document in the option's XML doc and in `docs/drivers/AbCip.md` (T20) that changing `transport` takes effect when the driver instance is recreated, which a deploy that changes the driver config does.
|
||
- Pin the current behaviour with a test, `Reinit_with_a_changed_transport_keeps_the_constructed_transport`, so a future change is deliberate.
|
||
- Before writing that test, confirm how a deploy applies a changed `DriverConfig`: `grep -rn "ReinitializeAsync\|CreateInstance" src/Server src/Core | grep -i driver`. If deploys recreate the instance, say so in the doc. If they call `ReinitializeAsync`, raise it as a follow-up in the commit message rather than widening this task.
|
||
- [ ] The **probe** deserializes with its own `JsonStringEnumConverter`, so a bad `"transport"` reports "Config JSON is invalid", not the `ParseEnum` message. That is acceptable; assert that exact behaviour in the probe test rather than the driver's.
|
||
- [ ] The probe with `Transport: Native` constructs a `NativeAbCipTagRuntime`. Prove it with a test that uses an unreachable `127.0.0.1:1` endpoint and asserts the failure message comes from the native path ("TCP connect … failed"), not libplctag's.
|
||
- [ ] The CLI `--transport native` reaches `AbCipDriverOptions.Transport`.
|
||
|
||
**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter AbCipTransportSwitch && dotnet test tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests` → pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Write the failing tests** (option parsing, factory selection, injected-wins, probe path).
|
||
- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests --filter AbCipTransportSwitch` → compile error.
|
||
- [ ] **Step 3: Implement.**
|
||
|
||
```csharp
|
||
// AbCipTransportFactories.cs
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||
|
||
internal static class AbCipTransportFactories
|
||
{
|
||
public static (IAbCipTagFactory Tags, IAbCipTagEnumeratorFactory Enumerators, IAbCipTemplateReaderFactory Templates)
|
||
For(AbCipTransport transport) => transport switch
|
||
{
|
||
AbCipTransport.Native => (new NativeAbCipTagFactory(), new NativeAbCipTagEnumeratorFactory(), new NativeAbCipTemplateReaderFactory()),
|
||
_ => (new LibplctagTagFactory(), new LibplctagTagEnumeratorFactory(), new LibplctagTemplateReaderFactory()),
|
||
};
|
||
}
|
||
```
|
||
|
||
In the `AbCipDriver` constructor:
|
||
|
||
```csharp
|
||
var defaults = AbCipTransportFactories.For(options.Transport);
|
||
_tagFactory = tagFactory ?? defaults.Tags;
|
||
_enumeratorFactory = enumeratorFactory ?? defaults.Enumerators;
|
||
_templateReaderFactory = templateReaderFactory ?? defaults.Templates;
|
||
```
|
||
|
||
- [ ] **Step 4: Run** the AbCip and CLI test suites → all pass.
|
||
- [ ] **Step 5: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests && git commit -m "feat(abcip): Transport option selects native or libplctag across driver, probe and CLI"`
|
||
|
||
---
|
||
|
||
### Task 16: AbLegacy `Transport` switch (options, factory, probe, CLI)
|
||
|
||
**Goal:** Do the same as Task 15 for AbLegacy.
|
||
|
||
**Files:**
|
||
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTransport.cs` (`public enum AbLegacyTransport { Libplctag, Native }`)
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs` (`Transport` property, default `Libplctag`)
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs` (DTO + parse)
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs:65-71` (`_tagFactory = tagFactory ?? (options.Transport == AbLegacyTransport.Native ? new NativeAbLegacyTagFactory() : new LibplctagLegacyTagFactory());`)
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs`
|
||
- Modify: `src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/` (`--transport`)
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs`
|
||
- Modify: `tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/`
|
||
|
||
**Acceptance Criteria:** the same five criteria as Task 15, applied to AbLegacy types:
|
||
- [ ] `"transport": "Native"` and `"native"` select the native factory, absent selects libplctag, and a bad value fails the way an existing bad enum field does.
|
||
- [ ] An injected factory wins over the option.
|
||
- [ ] The probe with Native uses the native path; prove it with the unreachable-endpoint message test.
|
||
- [ ] The CLI `--transport native` reaches the options.
|
||
- [ ] A reinit with a changed transport keeps the constructed transport (`AbLegacyDriver` reinit has the same Shutdown+Initialize shape), pinned by the same kind of test and documentation as Task 15.
|
||
|
||
**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter AbLegacyTransportSwitch && dotnet test tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests` → pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Write the failing tests:** option parsing (`"Native"`, `"native"`, absent, `"Bogus"`), injected-factory-wins, the probe's native-path message for `127.0.0.1:1`, and the CLI `--transport native` parse.
|
||
- [ ] **Step 2: Run and confirm failure.** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests --filter AbLegacyTransportSwitch` → compile error.
|
||
- [ ] **Step 3: Implement.**
|
||
- Add the enum, the options property and the DTO field. Parse it with the file's existing enum-parse helper, falling back to `AbLegacyTransport.Libplctag`.
|
||
- Use the constructor line given under **Files**.
|
||
- In the probe, build the runtime with `options.Transport == AbLegacyTransport.Native ? new NativeAbLegacyTagFactory() : new LibplctagLegacyTagFactory()` (or the injected factory from Task 3).
|
||
- Add `--transport native|libplctag` to the CLI, defaulting to `libplctag`, and change its "via libplctag" help text to "via libplctag or the native transport".
|
||
- [ ] **Step 4: Run** the AbLegacy and CLI test suites → all pass.
|
||
- [ ] **Step 5: Commit.** `git add src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests && git commit -m "feat(ablegacy): Transport option selects native or libplctag across driver, probe and CLI"`
|
||
|
||
---
|
||
|
||
### Task 17: AbCip + AbLegacy integration suites on both transports
|
||
|
||
**Goal:** Every existing `ab_server` integration test runs once per transport, and the native run is green wherever the libplctag run is green, against every compose profile.
|
||
|
||
**Files:**
|
||
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipReadSmokeTests.cs` and every other test in that project that constructs `AbCipDriverOptions` (`grep -rln "new AbCipDriverOptions" tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests`)
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/TransportMatrix.cs`
|
||
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadSmokeTests.cs` and siblings
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/TransportMatrix.cs`
|
||
- 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)
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] `TransportMatrix.Profiles` yields `(profile, transport)` for every profile × both transports. The theories switch from `MemberData(nameof(Profiles))` to `MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))`, and options get `Transport = transport`.
|
||
- [ ] **The AbLegacy matrix is its own class** in the AbLegacy integration project, using `AbLegacyTransport`, and it **preserves the existing `AB_LEGACY_COMPOSE_PROFILE` filter** that `AbLegacyReadSmokeTests.Profiles` applies today (`AbLegacyReadSmokeTests.cs:26-35`): it must filter `KnownProfiles.All` by that env var before crossing with transports. The AbCip snippet below does not apply to AbLegacy verbatim (review finding 20).
|
||
- [ ] Run against `10.100.0.35`, one compose profile at a time:
|
||
- AbCip: `controllogix`, `compactlogix`, `micro800`, `guardlogix`.
|
||
- AbLegacy: `slc500`, `micrologix`, `plc5`.
|
||
- **For each profile, the set of passing tests under `Native` ⊇ the set under `Libplctag`.**
|
||
- [ ] Any native-only failure is fixed in the transport task's code (reopen that task's files) and is not skipped. The only allowed exception is the PLC-5 `Unsupported` path from Task 8, marked `Skip = "PLC-5 framing unverified — see Task 8"` **only if** Task 8 set `Plc5Verified = false`.
|
||
- [ ] The results doc records, under its `## ab_server suites (T17)` heading, per profile × transport, the pass/fail/skip counts plus the `ab_server` image tag and the commit.
|
||
|
||
**Verify:** for each profile P (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"` → the Native pass set ⊇ the Libplctag pass set (compare the two trx files with `grep -o 'testName="[^"]*" .*outcome="[^"]*"'`).
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Add `TransportMatrix` and retarget every theory.**
|
||
|
||
```csharp
|
||
public static class TransportMatrix
|
||
{
|
||
public static IEnumerable<object[]> Profiles =>
|
||
from p in KnownProfiles.All
|
||
from t in new[] { AbCipTransport.Libplctag, AbCipTransport.Native }
|
||
select new object[] { p, t };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the suites per profile** as in Verify. AbLegacy uses its own Docker dir and its fixture's endpoint env var (read `AbLegacyServerFixture.cs` for the name).
|
||
- [ ] **Step 3: Triage every Native-only failure.** Reproduce it with the CLI (`--transport native` vs `--transport libplctag`), compare against the Task 4 capture, fix the code, add a unit test that pins the fix in the owning task's test file, and re-run.
|
||
- [ ] **Step 4: Write the results table** and tear down the fixtures.
|
||
- [ ] **Step 5: Commit.** `git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests docs/plans/2026-10-07-native-ab-transport-gate-results.md && git commit -m "test(ab): run the ab_server integration suites on both transports"`
|
||
|
||
---
|
||
|
||
### Task 18: Differential harness (libplctag vs native, same target)
|
||
|
||
**Goal:** A `LiveIntegration` test that reads the same tags through both transports against one endpoint and asserts equal raw buffers and equal decoded values. It skips cleanly when the endpoint env var is absent.
|
||
|
||
**Files:**
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/TransportDifferentialTests.cs`
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/DifferentialTarget.cs`
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs` and `NativeAbCipTagRuntime.cs` (add `internal ReadOnlyMemory<byte> RawBuffer`)
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj` (`InternalsVisibleTo` for `ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests`)
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] **Env gating:**
|
||
- `AB_DIFF_ENDPOINT` (`host:port`), `AB_DIFF_PATH` (default `1,0`), `AB_DIFF_FAMILY` (default `CompactLogix`).
|
||
- `AB_DIFF_TAGS`: an optional comma list. Absent ⇒ every atomic scalar, the first 20 arrays and the first 20 UDT members from browse.
|
||
- `AB_DIFF_MAX_TAGS` (default 200).
|
||
- Absent `AB_DIFF_ENDPOINT` ⇒ every test reports **skipped**, with the reason naming the variable.
|
||
- [ ] **The suite is read-only by construction.** It never calls `WriteAsync` or `EncodeValue`. A guard test reflects over `TransportDifferentialTests` and fails if any method body references `WriteAsync` (scan its source file).
|
||
- [ ] **Browse parity:** `@tags` plus every program listing through both enumerators decode to identical `AbCipDiscoveredTag` sets (order-insensitive). Every `TemplateInstanceId` seen decodes to an identical `AbCipUdtShape` through both template readers.
|
||
- [ ] **Read parity:** for each selected tag, under both transports:
|
||
- Run `InitializeAsync` + `ReadAsync`.
|
||
- Assert `GetStatus()` is equal, **except** that native `TypeMismatch` where libplctag says `Ok` is reported as a distinct *expected divergence* (spec §5), listed in the output and not failed.
|
||
- Assert the raw buffers are equal. To expose the raw bytes, add `internal ReadOnlyMemory<byte> RawBuffer` to both runtimes: libplctag's via `_tag.GetBuffer()`, native's via its stored data. Add `InternalsVisibleTo` for the integration test assembly in the AbCip csproj.
|
||
- Assert the decoded values are equal.
|
||
- [ ] **Connection discipline (review finding 9):**
|
||
- Before any native work, the harness acquires one `DeviceSessionPool.Shared.Acquire(key, options)` **anchor lease** for the target, and holds it until the run ends. Per-tag native runtimes therefore share that one connection: the pool never reaches refcount 0, so it never sends Forward Close and re-opens between tags.
|
||
- libplctag runtimes and native runtimes are never alive at the same time. All libplctag reads run first, and their handles are disposed. Then all native reads run. So the press sees at most two sessions over the run, never concurrently.
|
||
- With `AB_DIFF_COUNT_OPENS=1`, the native side asserts it made exactly **one** Forward Open over the whole run, using `CipConnection.ForwardOpenCount` from Task 9 (reset it at the start of the run).
|
||
- [ ] Output: a markdown table (tag, type, libplctag status, native status, equal?) written to `TestContext.Current.TestOutputHelper`.
|
||
- [ ] Results are appended under the gate-results doc's `## Differential vs ab_server (T18)` heading only.
|
||
- [ ] Run against `ab_server` (`controllogix` profile) with `AB_DIFF_ENDPOINT=10.100.0.35:44818` ⇒ zero unexpected divergences. Append the results to the gate-results doc.
|
||
- [ ] **Ordering:** blocked by Task 17, so any transport fixes found by the ab_server suites land before the differential run.
|
||
|
||
**Verify:** `AB_DIFF_ENDPOINT=10.100.0.35:44818 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --filter "FullyQualifiedName~Differential"` → pass with 0 unexpected divergences; without the env var → all skipped.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Write the harness.** Use the xUnit v3 `Assert.Skip(...)` call (the project is on xunit.v3), or a dynamic-skip attribute pattern matching `AbServerTheoryAttribute`.
|
||
- [ ] **Step 2: Run** against ab_server, fix any divergence (in the owning task's code, with a pinning unit test), and re-run until clean.
|
||
- [ ] **Step 3: Commit.** `git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip docs/plans/2026-10-07-native-ab-transport-gate-results.md && git commit -m "test(abcip): read-only differential harness comparing native and libplctag on one target"`
|
||
|
||
---
|
||
|
||
### Task 19: AdminUI `Transport` dropdown (AbCip + AbLegacy) + docker-dev `/run`
|
||
|
||
**Goal:** An operator can pick the transport per driver in the `/raw` driver-config modal, the value round-trips as a string, and a live docker-dev run proves it reaches the driver.
|
||
|
||
**Files:**
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor` (`FormModel.Transport`, `ToOptions`/`FromOptions`, an `InputSelect` in the advanced section)
|
||
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor` (same)
|
||
- Modify: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs` (add Transport round-trip cases)
|
||
- Create or modify: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs` (exists ⇒ add cases; absent ⇒ create, modelled on the AbLegacy file)
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] The dropdown has options "libplctag (default)" ⇒ `Libplctag` and "Native (managed)" ⇒ `Native`, bound with `@bind-Value:after="EmitAsync"` the way the other fields are.
|
||
- [ ] The serialized JSON contains `"transport":"Native"` (a string, not a number). It goes through the existing `_jsonOpts` with `JsonStringEnumConverter` (`AbCipDriverForm.razor:108-113`); verify the AbLegacy form has the same converter and add it if missing.
|
||
- [ ] Loading a config without `transport` shows "libplctag (default)" and re-emits **without** a `transport` key, or with `"Libplctag"`; either is fine, but pick one and test it.
|
||
- [ ] **Live `/run` on docker-dev** (login is disabled; drive it yourself with the Chrome tools against `http://localhost:9200`):
|
||
1. Bring up the AbCip `controllogix` fixture on `10.100.0.35`.
|
||
2. In `/raw`, create or edit an AbCip driver with a device `ab://10.100.0.35:44818/1,0`, set Transport = Native, and add the raw tag `TestDINT`.
|
||
3. Deploy.
|
||
4. Read the UNS/raw node with the Client CLI (`dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=<raw ns>;s=<RawPath>"`) and get `Good`.
|
||
5. Confirm from the central node's logs that the native runtime served it. Add one `LogDebug("AbCip transport={Transport}")` at driver init if no log line distinguishes the two.
|
||
6. Flip back to libplctag, redeploy, and read `Good` again.
|
||
7. Record the output under the gate-results doc's `## AdminUI /run (T19)` heading only.
|
||
- [ ] **Inverted-gate check** (memory: "an unchanged observable proves nothing"): with Transport = Native, point the device at a port where no `ab_server` is listening. The read must go **Bad** and the log must show the *native* error text. That proves the switch changes behaviour and isn't inert.
|
||
|
||
**Verify:** AdminUI unit tests pass, plus the docker-dev `/run` evidence (CLI output for both transports and the inverted case) is pasted into `docs/plans/2026-10-07-native-ab-transport-gate-results.md`.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Write the failing round-trip test.** In the two `*DriverPageFormSerializationTests.cs` files above, assert `ToOptions(FromOptions(x)).Transport == x.Transport` for both forms and both values, and that the serialized JSON carries `"transport":"Native"` as a string. Run it and confirm a compile error.
|
||
- [ ] **Step 2: Implement both forms.** Add the `Transport` field to each `FormModel` and to `ToOptions`/`FromOptions`, plus the `InputSelect` in the advanced section, and make sure the AbLegacy form's JSON options include `JsonStringEnumConverter`. Run the round-trip test → pass.
|
||
- [ ] **Step 3: Rebuild the docker-dev images** with `docker-dev/deploy.sh`, as `docker-dev/README.md` documents; do not guess flags.
|
||
- [ ] **Step 4: Run the live `/run` sequence** above and paste the evidence.
|
||
- [ ] **Step 5: Commit.** `git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI tests/Server docs/plans/2026-10-07-native-ab-transport-gate-results.md && git commit -m "feat(adminui): Transport dropdown on AbCip/AbLegacy driver forms (live-verified on docker-dev)"`
|
||
|
||
---
|
||
|
||
### Task 20: Documentation
|
||
|
||
**Goal:** Document the native transport, the switch, the known gaps and the client-visible type-check change, in the repo and in the umbrella history.
|
||
|
||
**Files:**
|
||
- Modify: `docs/drivers/AbCip.md` (sections: "Transport (`Transport` option)", "Native transport behaviour" covering connection sharing, MSP packing, the type check and press-safety limits, and "Known gaps" covering simulator-only writes, PLC-5/Micro800/routed paths, and libplctag deletion blocked on a bench controller)
|
||
- Modify: `docs/drivers/AbLegacy.md` (Transport section, PCCC simulator-only gap, PLC-5 status from Task 8, ST byte-order status from Task 14)
|
||
- Modify: `docs/drivers/AbServer-Test-Fixture.md` and `docs/drivers/AbLegacy-Test-Fixture.md` (suites now run per transport; the capture recipe `scripts/ab-capture/capture.sh`)
|
||
- Modify: `CLAUDE.md` (one short paragraph under a new heading "AB drivers: native transport (`Transport`)" with the switch, the confinement guard, and a pointer to the docs; keep it short, under 15 lines)
|
||
- Modify: `../scadaproj/.claude/skills/sister-project-history/otopcua.md` (append a dated entry), on scadaproj's `main` per the CLAUDE.md procedure
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] `docs/drivers/AbCip.md` states explicitly:
|
||
- The native path reports `BadTypeMismatch` for a same-width mis-authored type that libplctag reads as Good garbage, with an example (DINT authored, REAL returned).
|
||
- That is the fix working, and it is called out as client-visible.
|
||
- [ ] Every config key added (`transport`) is documented with its values, its default and string serialization.
|
||
- [ ] The scadaproj history entry is committed on `main` and pushed. The OtOpcUa index **row** in `../scadaproj/CLAUDE.md` is **not** touched; the summary does not change, since the driver set and wire contract are the same.
|
||
- [ ] The scadaproj branch is restored afterwards.
|
||
|
||
**Verify:** `grep -n "Transport" docs/drivers/AbCip.md docs/drivers/AbLegacy.md CLAUDE.md` shows the new sections; `cd ../scadaproj && git log origin/main -1 --format=%s` shows the history commit.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Write the repo docs** listed under **Files**. Take PLC-5 status from Task 8, ST byte-order status from Task 14, and the `/run` evidence from the gate-results doc.
|
||
- [ ] **Step 2: Do the scadaproj edit with the exact CLAUDE.md sequence:**
|
||
|
||
```bash
|
||
cd ~/Desktop/scadaproj
|
||
ORIG=$(git rev-parse --abbrev-ref HEAD); git stash list
|
||
git checkout main && git pull
|
||
# append to .claude/skills/sister-project-history/otopcua.md
|
||
git commit -am "docs(history): OtOpcUa AbCip/AbLegacy gain a native managed transport behind a Transport switch"
|
||
git push origin main
|
||
git checkout "$ORIG"
|
||
```
|
||
|
||
- [ ] **Step 3: Commit in OtOpcUa.** `git add docs/drivers CLAUDE.md && git commit -m "docs(ab): native transport, Transport switch, known gaps and the type-check change"`
|
||
|
||
---
|
||
|
||
### Task 21: Real-press read-only gate (CR CompactLogix)
|
||
|
||
**Goal:** Run the Task 18 differential harness and a latency comparison against one production CompactLogix 5069-L320ERS2 press at CR, **read-only**, and record the evidence that decides the AbCip default flip.
|
||
|
||
> **USER-ORDERED GATE — NON-SKIPPABLE.** This task was requested by the user in the current conversation. It MUST NOT be closed by walking around it, by declaring it "verified inline", or by substituting a cheaper check. Close only after every item in `acceptanceCriteria` has been re-validated independently, with output captured.
|
||
|
||
**Files:**
|
||
- Modify: `docs/plans/2026-10-07-native-ab-transport-gate-results.md` (append the press results)
|
||
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential/PollLatencyComparisonTests.cs`
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] **Preconditions (human):** the VPN to CR is up, the user has OK'd touching the press at this time, and the target is **one** press. The default is Z-35162 at `10.209.70.160:44818`, path `1,0`; any other press must be named by the user.
|
||
- [ ] **Run the differential:**
|
||
- `AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_DIFF_FAMILY=CompactLogix AB_DIFF_COUNT_OPENS=1 dotnet test …--filter "FullyQualifiedName~Differential"` passes.
|
||
- Browse parity holds: the same discovered tag set (the 2026-10-07 run saw ~23,412 variables across 80 programs) and identical UDT shapes.
|
||
- Read parity holds over `AB_DIFF_MAX_TAGS=200`, with zero unexpected divergences. Every `TypeMismatch` expected-divergence is listed by tag name.
|
||
- Native made exactly **1** Forward Open over the run. That is achievable only because T18's harness holds an anchor lease for the whole run; if the count is higher, the harness is broken, not the press.
|
||
- [ ] **Latency comparison** (`PollLatencyComparisonTests`, env-gated by the same variables):
|
||
- Read a fixed set of 100 atomic tags 30 times per transport, sequentially; transports are never concurrent. The native side runs under an anchor lease, as in T18.
|
||
- Native passes issue all 100 reads **concurrently** (`Task.WhenAll`), so the shared scheduler can MSP-pack them. libplctag passes do the same; libplctag packs internally.
|
||
- Record the CIP round-trips per pass (native: `CipConnection.RequestsSent` from Task 9, delta per pass; libplctag: report n/a) and the p50/p95 wall time per pass.
|
||
- Pass criterion: native p95 ≤ libplctag p95 × 1.10.
|
||
- [ ] **No writes:**
|
||
- Evidence: the Task 18 source guard passes.
|
||
- `grep -c WriteAsync` over both differential test files returns 0.
|
||
- [ ] **Recorded results:** under the `## Real press (T21)` and `## Latency (T21)` headings, the results doc carries the raw `dotnet test` output, the divergence table, the latency table, the Forward Open count, the date/time (UTC) of the run and the press ID.
|
||
- [ ] **Failure handling:** if any criterion fails, the task stays open. Fix the code (with a pinning test), re-run the ab_server suites (Task 17) and the press run. **Do not** relax the criterion.
|
||
|
||
**Verify:** `AB_DIFF_ENDPOINT=10.209.70.160:44818 AB_DIFF_FAMILY=CompactLogix AB_DIFF_COUNT_OPENS=1 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests --filter "FullyQualifiedName~Differential" --logger "console;verbosity=detailed"` → passed, 0 unexpected divergences, `ForwardOpenCount=1`, and native p95 ≤ 1.10 × libplctag p95.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1:** Write `PollLatencyComparisonTests` (skips without the env vars). Run it against ab_server first to prove it works.
|
||
- [ ] **Step 2:** Ask the user to confirm the VPN, the press and the time window. Do not proceed without an explicit yes.
|
||
- [ ] **Step 3:** Run the differential, then the latency comparison, **sequentially**.
|
||
- [ ] **Step 4:** Paste the evidence into the results doc.
|
||
- [ ] **Step 5: Commit.** `git add tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Differential docs/plans/2026-10-07-native-ab-transport-gate-results.md && git commit -m "test(abcip): real-press read-only gate — native vs libplctag differential and latency (Z-35162)"`
|
||
|
||
---
|
||
|
||
### Task 22: Flip the defaults to Native + release note
|
||
|
||
**Goal:** Make `Native` the default transport for both drivers (spec §8 P5), keeping `Libplctag` selectable, and write the client-visible release note.
|
||
|
||
**Files:**
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs` (default `AbCipTransport.Native`)
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverFactoryExtensions.cs` (`fallback: AbCipTransport.Native`)
|
||
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs`, `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs` (same)
|
||
- Modify: both AdminUI forms (relabel the options as "Native (default)" / "libplctag (fallback)")
|
||
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTransportSwitchTests.cs`, `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTransportSwitchTests.cs` (the absent ⇒ Native expectation)
|
||
- Modify: the CLIs' `--transport` default (`native`)
|
||
- Modify: `docs/drivers/AbCip.md`, `docs/drivers/AbLegacy.md` (the default, and a "Release note" subsection)
|
||
|
||
**Acceptance Criteria:**
|
||
- [ ] Blocked by Task 21 passing **and** Task 17 green for all AbLegacy profiles.
|
||
- [ ] A config with no `transport` key runs Native in both drivers; prove it with the switch tests.
|
||
- [ ] Existing unit tests that construct `AbCipDriver` with **no** injected factory and expect libplctag behaviour are updated to set `Transport = Libplctag` explicitly. Find them with `grep -rn "new AbCipDriver(" tests | grep -v tagFactory`.
|
||
- [ ] The release note says:
|
||
- The native transport is the default.
|
||
- It reports `BadTypeMismatch` for mis-authored same-width types.
|
||
- Set `"transport": "Libplctag"` to fall back.
|
||
- AbCip writes and all of AbLegacy are simulator-proven only.
|
||
- [ ] The full `dotnet test ZB.MOM.WW.OtOpcUa.slnx` passes, except suites that need fixtures that are not up; those are listed as skipped.
|
||
|
||
**Verify:** `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests` → all pass.
|
||
|
||
**Steps:**
|
||
|
||
- [ ] **Step 1: Write the failing tests.** Flip the switch tests' absent ⇒ X expectation to `Native` in `AbCipTransportSwitchTests` and `AbLegacyTransportSwitchTests`. Run them and confirm they fail.
|
||
- [ ] **Step 2: Flip the defaults.** Change the four defaults (two options classes, two `fallback:` arguments), relabel the AdminUI options, and change the CLIs' default to `native`. Run the switch tests → pass.
|
||
- [ ] **Step 3: Pin the remaining libplctag tests.** Find every test that builds a driver with no injected factory and relies on libplctag behaviour (`grep -rn "new AbCipDriver(" tests | grep -v tagFactory`, and the same for `AbLegacyDriver`), and set `Transport = Libplctag` on each.
|
||
- [ ] **Step 4: Write the release-note subsection** in both driver docs.
|
||
- [ ] **Step 5: Run the full suite.**
|
||
- [ ] **Step 6: Commit.** `git add src tests docs && git commit -m "feat(ab): native managed transport is the default for AbCip and AbLegacy; libplctag remains as fallback"`
|
||
|
||
---
|
||
|
||
## Self-review notes (author)
|
||
|
||
- **Spec coverage:**
|
||
- D1–D9 → T1–T16.
|
||
- §5 status mapping → T1, T2, T3.
|
||
- §5 press safety → T9 (throttle, storm test), T18 (single connection), T21 (one press, read-only).
|
||
- §6 packaging → T1, T11.
|
||
- §7.1 golden frames → T4–T8.
|
||
- §7.2 scheduler and connection tests → T9, T10.
|
||
- §7.3 both-transport suites → T17.
|
||
- §7.4 differential harness → T18, T21.
|
||
- §7.5 AdminUI `/run` → T19.
|
||
- §8 P5 flip → T22.
|
||
- P6 is out of plan, as recorded.
|
||
- **Deviations from the spec:**
|
||
- `MaxInFlight` is fixed at 1 (see Global Constraints).
|
||
- `LibplctagTagEnumerator` is renamed `SymbolListingEnumerator` (T13), because its decode loop is transport-neutral.
|
||
- `AbCipTagCreateParams` gains `AuthoredType` and `UseConnectedMessaging` (T12).
|
||
- Explicit `CipRouting` modes (T9): the spec assumed only connected-or-UCMM, but libplctag's PCCC framing needs a third, `Direct`.
|
||
- **Fable review (2026-10-07)** findings B1–B3 and 4–22 are folded in: the scheduler is per connection, caller cancellation is non-faulting, routing modes, the T5←T4 and T19←T17 dependencies, the narrowed guard regex, the 0x08 Large-FO fallback, keep-alive, the anchor lease, registration disposal, the BOOL 0xD3 exemption, PCCC chunking, `FromCip` parity, fixture locations, fragment continuation, the open-pair race, extra test files, the profile-driven routing flag, reinit semantics, the AbLegacy matrix filter, per-task models, and the bounded lease dispose.
|