# Modbus RTU Driver Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.

**Goal:** Add a Modbus **RTU-over-TCP** transport (RTU CRC-16 framing tunnelled to a serial→Ethernet gateway) to the existing `ModbusDriver`, selected by a new `Transport` config field — reusing every codec/planner/health/materialisation surface unchanged.

**Architecture:** Purely additive behind the existing `IModbusTransport` seam. The application protocol (function codes, codecs, planner, coalescing, write path, probe, materialisation, HistoryRead) is byte-for-byte identical across TCP and RTU — only the wire framing differs (`[addr][PDU][CRC-16]`, no MBAP, no TxId). Work = a CRC-16 helper + a length-less FC-aware framer + one new socket transport that composes a socket lifecycle extracted from `ModbusTcpTransport` + a transport-mode selector on the config + one AdminUI field. **Zero** changes to codecs/planner/health.

**Tech Stack:** existing `ZB.MOM.WW.OtOpcUa.Driver.Modbus` (+ `.Contracts`, `.Addressing`), xUnit + Shouldly, `pymodbus[simulator]==3.13.0` Docker fixture (RTU-framed-TCP profile; stdlib fallback server modelled on `exception_injector.py`), Blazor `ModbusDriverForm.razor` (AdminUI). No new NuGet dependency.

**Source design:** docs/plans/2026-07-15-modbus-rtu-driver-design.md
**Program design:** docs/plans/2026-07-15-driver-expansion-program-design.md (§3 shared contract, §7 fixtures) — this is **Wave 1**.
**Progress tracker:** docs/plans/2026-07-24-driver-expansion-tracking.md

## Scope (P1 shippable v1 only)

RTU-over-TCP via a serial→Ethernet gateway (Moxa/Digi/Lantronix), RTU CRC-16 (poly `0xA001`) + FC-aware length framing, a `Transport` selector (`Tcp` | `RtuOverTcp`) on the Modbus config, the `rtu_over_tcp` pymodbus docker profile, and the AdminUI selector. Read **and** write supported (same as TCP).

## Deferred / out of scope

- **Direct-serial transport** (`ModbusRtuTransport` / `System.IO.Ports`) — descoped (user, 2026-07-15). RTU buses are reached exclusively via a serial→Ethernet gateway. Design record kept in **design §2b / §9 P2**; the `ModbusRtuFraming` built here is byte-stream-agnostic so a future serial leg would reuse it unchanged. Do **not** number-squat an `Rtu` enum member for it.
- **Modbus ASCII** — a third framing (`:`-delimited hex + LRC); would be another `IModbusTransport` if ever needed (design §2c). Not planned.
- **Per-tag `TagConfig` changes** — none. `ModbusTagDefinition`/`ModbusTagDto` already carry the per-tag `UnitId` override that makes an RS-485 multi-drop bus work; the read planner already refuses to coalesce across UnitIds. Additions are **driver-level only** (design §4).
- **`ModbusDriver.BuildSlaveHostName` change** — NOT needed. It already emits `host:port/unit{n}`, the correct per-slave resilience key for a gateway (design §6). The design §1 table listed it only for the descoped serial (COM) case.

## Cross-cutting rules (program §3.1 — mandatory)

- **Enum-serialization trap** — `Transport` MUST round-trip as a **name string**, never a number, on both the AdminUI serializer (`ModbusDriverForm._jsonOpts` already carries `JsonStringEnumConverter`) and the factory (DTO field typed `string?`, parsed via the existing `ParseEnum<T>` — mirror `Family`/`MelsecSubFamily`; **do not** type the DTO field as the enum). Guarded by an explicit `"transport":"RtuOverTcp"` string assertion (design §5).
- **Per-op deadline (R2-01)** — the RTU transport MUST run every transaction under a linked-CTS `CancelAfter(Options.Timeout)` — a frozen gateway must never wedge a poll. There is **no MBAP length field to trust**; FC-aware sizing is the only length signal, so getting it right (or timing out) is the top correctness risk (design §7).
- **Single-flight mandatory on RTU** — no TxId means at most one transaction on the bus at a time. Keep the `_gate` `SemaphoreSlim` in the new transport.
- **Ctor is connection-free** — the transport constructor must not connect; all I/O happens in `ConnectAsync`/`SendAsync`.
- **Live-verify discipline** — the picker/editor + deploy path get a docker-dev `/run` before trust (Task 10).

---

## Task 0: Add the `ModbusTransportMode` enum

**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 1, Task 3
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusTransportMode.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportModeTests.cs`

The enum lives in `.Contracts` (backend-dep-free), namespace `ZB.MOM.WW.OtOpcUa.Driver.Modbus` — same namespace as `ModbusDriverOptions`.

**Step 1 — failing test:**
```csharp
using Shouldly;
using Xunit;

namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;

public sealed class ModbusTransportModeTests
{
    [Fact]
    public void Tcp_is_the_default_zero_member()
        => ((ModbusTransportMode)0).ShouldBe(ModbusTransportMode.Tcp);

    [Fact]
    public void Exactly_two_members_ship_in_v1()
        => Enum.GetNames<ModbusTransportMode>().ShouldBe(["Tcp", "RtuOverTcp"]);
}
```

**Step 2 — run & expect FAIL** (type does not exist / does not compile):
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusTransportModeTests"`

**Step 3 — minimal impl:**
```csharp
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;

/// <summary>
///     Wire transport for a Modbus driver instance. <see cref="Tcp"/> = Modbus/TCP (MBAP + TxId);
///     <see cref="RtuOverTcp"/> = RTU framing (address + CRC-16, no MBAP) tunnelled over a socket to
///     a serial→Ethernet gateway. Default <see cref="Tcp"/> — existing configs omit the field.
///     A direct-serial <c>Rtu</c> member is intentionally NOT reserved here (descoped 2026-07-15);
///     do not number-squat it.
/// </summary>
public enum ModbusTransportMode
{
    /// <summary>Modbus/TCP — 7-byte MBAP header + transaction id (the historical default).</summary>
    Tcp,

    /// <summary>RTU framing (<c>[addr][PDU][CRC-16]</c>) tunnelled over a socket to a serial→Ethernet gateway.</summary>
    RtuOverTcp,
}
```

**Step 4 — run & expect PASS.**

**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): add ModbusTransportMode enum (Tcp | RtuOverTcp)

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`

---

## Task 1: `ModbusCrc` — CRC-16 (poly 0xA001) helper + golden vectors

**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 0, Task 3
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusCrc.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCrcTests.cs`

CRC-16/MODBUS: reflected poly `0xA001`, init `0xFFFF`, appended **low byte first**. This is the top-of-stack of the length-less framer — golden vectors are the whole point.

**Step 1 — failing test** (table-driven against published Modbus CRC vectors):
```csharp
using Shouldly;
using Xunit;

namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;

public sealed class ModbusCrcTests
{
    // Known CRC-16/MODBUS vectors (init 0xFFFF, poly 0xA001). CRC returned as ushort;
    // on the wire it is appended low-byte-first.
    [Theory]
    // FC03 read HR: unit 1, addr 0, qty 1  -> CRC 0x0A84  (bytes 84 0A on the wire)
    [InlineData(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 }, (ushort)0x0A84)]
    // "123456789" canonical check value for CRC-16/MODBUS = 0x4B37
    [InlineData(new byte[] { 0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39 }, (ushort)0x4B37)]
    public void Compute_matches_known_vectors(byte[] frame, ushort expected)
        => ModbusCrc.Compute(frame).ShouldBe(expected);

    [Fact]
    public void AppendLowByteFirst_writes_lo_then_hi()
    {
        var body = new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 };
        var withCrc = ModbusCrc.Append(body);
        withCrc.Length.ShouldBe(body.Length + 2);
        withCrc[^2].ShouldBe((byte)0x84); // CRC low byte
        withCrc[^1].ShouldBe((byte)0x0A); // CRC high byte
    }
}
```

**Step 2 — run & expect FAIL:**
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusCrcTests"`

**Step 3 — minimal impl** (`ModbusCrc.cs`): a `static ushort Compute(ReadOnlySpan<byte>)` (init `0xFFFF`, xor byte, 8× shift-right, xor `0xA001` on carry) and `static byte[] Append(ReadOnlySpan<byte> body)` returning `body + [crc & 0xFF, crc >> 8]`. ~30 lines. If a golden vector disagrees, fix the CRC math — never the vector.

**Step 4 — run & expect PASS.**

**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): add ModbusCrc CRC-16/MODBUS helper + golden vectors

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`

---

## Task 2: `ModbusRtuFraming` — ADU build + FC-aware length-less deframe

**Classification:** high-risk (framing)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuFraming.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusRtuFramingTests.cs`

The one genuinely new correctness risk (design §2a/§7): RTU frames carry **no length field**, so response size is parsed from the function code. Read `addr(1)`+`fc(1)`, then: **exception** (`fc & 0x80`) → `excCode(1)+CRC(2)`; **reads FC01/02/03/04** → `byteCount(1)` then `byteCount + CRC(2)`; **write echoes FC05/06/15/16** → fixed `4 + CRC(2)`. Validate trailing CRC, strip `addr`+`CRC`, return the bare PDU `[fc, ...data]`. CRC mismatch / truncation → `ModbusTransportDesyncException`; exception PDU (after CRC-validate) → `ModbusException`. Test directly against an in-memory `MemoryStream` of canned bytes.

**Step 1 — failing test:**
```csharp
using Shouldly;
using Xunit;

namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;

public sealed class ModbusRtuFramingTests
{
    private static MemoryStream Canned(params byte[] frame) => new(frame);

    [Fact]
    public void BuildAdu_prefixes_unit_and_appends_crc()
    {
        var adu = ModbusRtuFraming.BuildAdu(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 });
        adu.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A });
    }

    [Fact]
    public async Task ReadResponse_FC03_returns_bare_pdu()
    {
        // addr=01 fc=03 byteCount=02 data=00 0A  + CRC(lo,hi)
        var body = new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A };
        var frame = ModbusCrc.Append(body);
        await using var s = Canned(frame);
        var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
        pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A }); // fc + byteCount + data, no addr/CRC
    }

    [Fact]
    public async Task ReadResponse_exception_frame_throws_ModbusException()
    {
        // addr=01 fc=0x83 exc=0x02 + CRC
        var frame = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 });
        await using var s = Canned(frame);
        var ex = await Should.ThrowAsync<ModbusException>(
            ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
        ex.FunctionCode.ShouldBe((byte)0x03);
        ex.ExceptionCode.ShouldBe((byte)0x02);
    }

    [Fact]
    public async Task ReadResponse_bad_crc_throws_desync()
    {
        var frame = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
        frame[^1] ^= 0xFF; // corrupt CRC high byte
        await using var s = Canned(frame);
        await Should.ThrowAsync<ModbusTransportDesyncException>(
            ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken));
    }

    [Fact]
    public async Task ReadResponse_FC06_write_echo_returns_four_byte_pdu()
    {
        // addr=01 fc=06 addr=00 07 value=00 2A + CRC  -> PDU = 06 00 07 00 2A
        var frame = ModbusCrc.Append(new byte[] { 0x01, 0x06, 0x00, 0x07, 0x00, 0x2A });
        await using var s = Canned(frame);
        var pdu = await ModbusRtuFraming.ReadResponsePduAsync(s, 0x01, TestContext.Current.CancellationToken);
        pdu.ShouldBe(new byte[] { 0x06, 0x00, 0x07, 0x00, 0x2A });
    }
}
```

**Step 2 — run & expect FAIL:**
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusRtuFramingTests"`

**Step 3 — minimal impl** (`ModbusRtuFraming.cs`): `static byte[] BuildAdu(byte unitId, ReadOnlySpan<byte> pdu)` = `ModbusCrc.Append([unitId, ..pdu])`; `static async Task<byte[]> ReadResponsePduAsync(Stream, byte expectedUnit, CancellationToken)` doing the FC-aware read described above with a `ReadExactlyAsync` helper (mirror `ModbusTcpTransport.ReadExactlyAsync`). Reuse `ModbusException` + `ModbusTransportDesyncException` (both already in the project). Keep framing byte-stream-agnostic (no socket knowledge) so a future serial transport can reuse it.

**Step 4 — run & expect PASS.**

**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): add ModbusRtuFraming (ADU build + FC-aware length-less deframe)

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`

---

## Task 3: Extract `ModbusSocketLifecycle` from `ModbusTcpTransport` (behaviour-preserving)

**Classification:** high-risk (transport / concurrency)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 0, Task 1, Task 2
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusSocketLifecycle.cs`
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusSocketLifecycleTests.cs`

Mechanical refactor: move the IPv4-preference connect (`ConnectAsync`), `EnableKeepAlive`/`ClampToWholeSeconds`, `ConnectWithBackoffAsync`, `TearDownAsync`, and the `_lastSuccessUtc`/idle-disconnect tracking out of `ModbusTcpTransport` into a reusable `ModbusSocketLifecycle` that exposes the current `NetworkStream`. `ModbusTcpTransport` keeps its MBAP `SendOnceAsync` and composes the lifecycle. **Behaviour must be byte-for-byte unchanged** — the existing `ModbusTcpReconnectTests` + `ModbusConnectionOptionsTests` are the regression net; run the whole Modbus.Tests suite green before committing.

**Step 1 — failing test** (pins the extracted surface; `ClampToWholeSeconds` moves with it):
```csharp
using Shouldly;
using Xunit;

namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;

public sealed class ModbusSocketLifecycleTests
{
    [Theory]
    [InlineData(0.4, 1)]   // sub-second rounds up to 1 (the int-cast truncation guard)
    [InlineData(2.0, 2)]
    [InlineData(-5.0, 1)]  // negative clamps to 1
    public void ClampToWholeSeconds_rounds_up_min_one(double seconds, int expected)
        => ModbusSocketLifecycle.ClampToWholeSeconds(TimeSpan.FromSeconds(seconds)).ShouldBe(expected);

    [Fact]
    public async Task Connect_to_dead_port_surfaces_socket_failure()
    {
        var life = new ModbusSocketLifecycle("127.0.0.1", 1, TimeSpan.FromMilliseconds(300),
            autoReconnect: false);
        await Should.ThrowAsync<System.Net.Sockets.SocketException>(
            life.ConnectAsync(TestContext.Current.CancellationToken));
    }
}
```

**Step 2 — run & expect FAIL:**
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusSocketLifecycleTests"`

**Step 3 — minimal impl:** create `ModbusSocketLifecycle` holding `_host/_port/_timeout/_autoReconnect/_keepAlive/_idleDisconnect/_reconnect`, the `TcpClient`/`NetworkStream`, and `_lastSuccessUtc`; expose `ConnectAsync`, `ConnectWithBackoffAsync`, `TearDownAsync`, `Stream` (current), `MarkSuccess()`, `ShouldReconnectForIdle()`, and the `static int ClampToWholeSeconds`. Re-point `ModbusTcpTransport` to delegate connect/reconnect/teardown/idle to it while keeping MBAP `SendOnceAsync` in place. Move (don't duplicate) `ClampToWholeSeconds` — update its one internal caller/test reference.

**Step 4 — run & expect PASS** — then run the **whole** suite to prove no regression:
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests`

**Step 5 — commit:**
`git commit -am "refactor(modbus): extract ModbusSocketLifecycle from ModbusTcpTransport (behaviour-preserving)

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`

---

## Task 4: `ModbusRtuOverTcpTransport` — RTU framing over the socket lifecycle

**Classification:** high-risk (framing / transport / concurrency)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusRtuOverTcpTransport.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusRtuOverTcpTransportTests.cs`

`IModbusTransport` implementation: composes `ModbusSocketLifecycle` (Task 3) for connect/reconnect/keepalive/idle and `ModbusRtuFraming` (Task 2) for send/receive. Delta from `ModbusTcpTransport`: CRC framing instead of MBAP, **no TxId**. Keep the `_gate` `SemaphoreSlim` single-flight (mandatory — no TxId), the single reconnect-retry shape, and a linked-CTS `CancelAfter(Options.Timeout)` per-op deadline (design §7, R2-01). **Seam choice for unit test:** add an `internal` test ctor accepting a pre-connected `Stream` (an in-memory duplex fake) that bypasses `ConnectAsync` — this is "the fake one level below the `IModbusTransport` fakes" the design §8 calls for, and lets the send/receive orchestration + single-flight + deadline be tested with no socket.

**Step 1 — failing test** (duplex fake: canned response stream + capture of written request bytes):
```csharp
using Shouldly;
using Xunit;

namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;

public sealed class ModbusRtuOverTcpTransportTests
{
    [Fact]
    public async Task SendAsync_writes_rtu_adu_and_returns_deframed_pdu()
    {
        // Response the fake gateway will emit: FC03, 1 reg = 0x000A.
        var response = ModbusCrc.Append(new byte[] { 0x01, 0x03, 0x02, 0x00, 0x0A });
        var fake = new CapturingDuplexStream(response);

        await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromSeconds(2));
        var pdu = await transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
            TestContext.Current.CancellationToken);

        pdu.ShouldBe(new byte[] { 0x03, 0x02, 0x00, 0x0A });
        // The request went out as an RTU ADU: unit + pdu + CRC, NO MBAP header.
        fake.Written.ShouldBe(new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01, 0x84, 0x0A });
    }

    [Fact]
    public async Task SendAsync_exception_pdu_surfaces_ModbusException()
    {
        var response = ModbusCrc.Append(new byte[] { 0x01, 0x83, 0x02 });
        var fake = new CapturingDuplexStream(response);
        await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromSeconds(2));
        await Should.ThrowAsync<ModbusException>(
            transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
                TestContext.Current.CancellationToken));
    }

    [Fact]
    public async Task SendAsync_stalled_gateway_hits_per_op_deadline()
    {
        var fake = new CapturingDuplexStream(respondBytes: Array.Empty<byte>(), stall: true);
        await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromMilliseconds(200));
        await Should.ThrowAsync<ModbusTransportDesyncException>(
            transport.SendAsync(0x01, new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 },
                TestContext.Current.CancellationToken));
    }
}
```
(The `CapturingDuplexStream` test double — a `Stream` that records writes and replays `respondBytes` on read, or blocks forever when `stall` — lives in the test file.)

**Step 2 — run & expect FAIL:**
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusRtuOverTcpTransportTests"`

**Step 3 — minimal impl:** production ctor takes the same connection params as `ModbusTcpTransport` and builds a `ModbusSocketLifecycle`; `internal static ForTest(Stream, TimeSpan)` injects a pre-connected stream. `SendAsync`: `_gate.WaitAsync`; idle-reconnect check (skipped in test seam); write `ModbusRtuFraming.BuildAdu(unitId, pdu)`, flush, `ModbusRtuFraming.ReadResponsePduAsync(...)` under a linked `CancelAfter(_timeout)`; timeout-vs-caller-cancel distinction + teardown mirror `ModbusTcpTransport.SendOnceAsync`; single reconnect-retry on socket-level failure.

**Step 4 — run & expect PASS.**

**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): add ModbusRtuOverTcpTransport (RTU framing over socket lifecycle)

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`

---

## Task 5: `ModbusTransportFactory.Create` — switch on `Transport`

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 6
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTransportFactory.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportFactoryTests.cs`

One place that maps `ModbusDriverOptions` → the right `IModbusTransport`, consumed by both the driver default closure (Task 7) and the probe (Task 7). `Tcp` → `ModbusTcpTransport`, `RtuOverTcp` → `ModbusRtuOverTcpTransport`; both get `Host/Port/Timeout/AutoReconnect/KeepAlive/IdleDisconnect/Reconnect`.

**Step 1 — failing test:**
```csharp
using Shouldly;
using Xunit;

namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;

public sealed class ModbusTransportFactoryTests
{
    [Fact]
    public void Tcp_mode_builds_tcp_transport()
        => ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.Tcp })
            .ShouldBeOfType<ModbusTcpTransport>();

    [Fact]
    public void RtuOverTcp_mode_builds_rtu_transport()
        => ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp })
            .ShouldBeOfType<ModbusRtuOverTcpTransport>();

    [Fact]
    public void Default_options_build_tcp_transport()
        => ModbusTransportFactory.Create(new ModbusDriverOptions())
            .ShouldBeOfType<ModbusTcpTransport>();
}
```
(Depends on Task 6's `Transport` property existing on `ModbusDriverOptions` — sequence Task 6 or add the property first. If Task 5 lands before Task 6, add the property in this task instead.)

**Step 2 — run & expect FAIL.**
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusTransportFactoryTests"`

**Step 3 — minimal impl:** `static IModbusTransport Create(ModbusDriverOptions o)` = `switch (o.Transport) { RtuOverTcp => new ModbusRtuOverTcpTransport(...), _ => new ModbusTcpTransport(...) }`, passing the shared connection params.

**Step 4 — run & expect PASS.**

**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): add ModbusTransportFactory switch on Transport mode

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`

---

## Task 6: Add `Transport` to options + DTO + factory (string-enum guard)

**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 5
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ModbusDriverOptions.cs`
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportConfigRoundTripTests.cs`

Add `public ModbusTransportMode Transport { get; init; } = ModbusTransportMode.Tcp;` to `ModbusDriverOptions`. On the DTO: add `public string? Transport { get; init; }` (typed **`string?`**, per the Modbus factory pattern — design §5) and map it with the existing `ParseEnum<ModbusTransportMode>(...)` helper, defaulting to `Tcp` when null. The guard test asserts the string wire form.

**Step 1 — failing test:**
```csharp
using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;

namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;

public sealed class ModbusTransportConfigRoundTripTests
{
    // Mirrors the AdminUI ModbusDriverForm serializer: camelCase + string enums.
    private static readonly JsonSerializerOptions _adminOpts = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        Converters = { new JsonStringEnumConverter() },
    };

    [Fact]
    public void Transport_serializes_as_a_string_not_a_number()
    {
        var opts = new ModbusDriverOptions { Host = "10.20.0.50", Port = 4001, Transport = ModbusTransportMode.RtuOverTcp };
        var json = JsonSerializer.Serialize(opts, _adminOpts);
        json.ShouldContain("\"transport\":\"RtuOverTcp\"");
        json.ShouldNotContain("\"transport\":1", Case.Sensitive);
    }

    [Fact]
    public void Factory_binds_RtuOverTcp_from_string_config()
    {
        const string json = """{ "host": "10.20.0.50", "port": 4001, "transport": "RtuOverTcp" }""";
        using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-rtu", json);
        var opts = (ModbusDriverOptions)typeof(ModbusDriver)
            .GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
            .GetValue(driver)!;
        opts.Transport.ShouldBe(ModbusTransportMode.RtuOverTcp);
    }

    [Fact]
    public void Factory_defaults_Transport_to_Tcp_when_omitted()
    {
        const string json = """{ "host": "10.0.0.10" }""";
        using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-tcp", json);
        var opts = (ModbusDriverOptions)typeof(ModbusDriver)
            .GetField("_options", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
            .GetValue(driver)!;
        opts.Transport.ShouldBe(ModbusTransportMode.Tcp);
    }
}
```

**Step 2 — run & expect FAIL:**
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusTransportConfigRoundTripTests"`

**Step 3 — minimal impl:** add the `Transport` property (options) + DTO `string?` field + factory mapping `Transport = dto.Transport is null ? ModbusTransportMode.Tcp : ParseEnum<ModbusTransportMode>(dto.Transport, "<driver-level>", driverInstanceId, "Transport")`.

**Step 4 — run & expect PASS.**

**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): bind Transport mode on options/DTO/factory (string-enum guard)

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`

---

## Task 7: Wire the driver default closure + probe to `ModbusTransportFactory`

**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 8
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs`
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusTransportWiringTests.cs`

Replace the driver ctor's hardcoded `new ModbusTcpTransport(...)` default closure with `o => ModbusTransportFactory.Create(o)`. In the probe, replace the hardcoded `new ModbusTcpTransport(host, port, timeout, autoReconnect: false)` (line ~77) with a factory build carrying `Transport` (parse it into `ProbeTarget` from the DTO), `autoReconnect: false`. Now an `RtuOverTcp`-authored config probes over RTU framing — matching how it will actually run.

**Step 1 — failing test** (the default closure honours the mode; the probe carries it):
```csharp
using Shouldly;
using Xunit;

namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;

public sealed class ModbusTransportWiringTests
{
    private static IModbusTransport BuildDefaultTransport(ModbusDriverOptions opts)
    {
        using var driver = new ModbusDriver(opts, "wire-test");
        var factory = (Func<ModbusDriverOptions, IModbusTransport>)typeof(ModbusDriver)
            .GetField("_transportFactory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
            .GetValue(driver)!;
        return factory(opts);
    }

    [Fact]
    public void Default_closure_builds_rtu_transport_for_RtuOverTcp()
        => BuildDefaultTransport(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp })
            .ShouldBeOfType<ModbusRtuOverTcpTransport>();

    [Fact]
    public void Default_closure_builds_tcp_transport_for_Tcp()
        => BuildDefaultTransport(new ModbusDriverOptions())
            .ShouldBeOfType<ModbusTcpTransport>();
}
```
(If `_transportFactory` reflection proves brittle, assert instead that a `RtuOverTcp` driver initialised against an unreachable host degrades without ever constructing an MBAP frame — but the closure-reflection test is the tightest.)

**Step 2 — run & expect FAIL:**
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusTransportWiringTests"`

**Step 3 — minimal impl:** driver ctor default = `?? (o => ModbusTransportFactory.Create(o))`; probe: add `ModbusTransportMode Transport` to `ProbeTarget`, parse `dto.Transport` via `ParseEnum`, and build the probe transport through `ModbusTransportFactory.Create(new ModbusDriverOptions { Host, Port, Timeout, Transport, AutoReconnect = false })`.

**Step 4 — run & expect PASS** (+ run the full Modbus.Tests suite green).

**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): route driver + probe transports through ModbusTransportFactory

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`

---

## Task 8: AdminUI — `Transport` selector on `ModbusDriverForm`

**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 7
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/ModbusDriverForm.razor`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusDriverFormModelTests.cs`

Add a `Transport` `<InputSelect>` to the **Protocol** panel (design's target `ModbusDriverPage.razor` is retired; `ModbusDriverForm.razor` is the live host — it serializes `ModbusDriverOptions` directly through `_jsonOpts`, which already carries `JsonStringEnumConverter`, so `Transport` emits as a name string automatically; `Transport` is **not** an endpoint key so it is not stripped by `GetConfigJson`). Add `public ModbusTransportMode Transport { get; set; } = ModbusTransportMode.Tcp;` to `FormModel`, map it in `FromOptions`/`ToOptions`. Host/Port stay on `ModbusDeviceForm` — a `RtuOverTcp` gateway uses the same `host:port` shape. Extend the existing `ModbusDriverFormModelTests` (round-trip + string-enum guard).

**Step 1 — failing test** (append to `ModbusDriverFormModelTests`):
```csharp
    [Fact]
    public void Round_trip_preserves_Transport_mode()
    {
        var form = new ModbusDriverForm.FormModel { Transport = ModbusTransportMode.RtuOverTcp };
        var json = JsonSerializer.Serialize(form.ToOptions(), JsonOpts);
        var back = ModbusDriverForm.FormModel.FromOptions(
            JsonSerializer.Deserialize<ModbusDriverOptions>(json, JsonOpts)!);
        back.Transport.ShouldBe(ModbusTransportMode.RtuOverTcp);
        json.ShouldContain("\"transport\":\"RtuOverTcp\""); // name string, never a number
    }
```

**Step 2 — run & expect FAIL:**
`dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~ModbusDriverFormModelTests"`

**Step 3 — minimal impl:** add the `Transport` `FormModel` property + `FromOptions`/`ToOptions` mapping, and an `<InputSelect @bind-Value="_form.Transport" @bind-Value:after="EmitAsync">` looping `Enum.GetValues<ModbusTransportMode>()` in the Protocol panel, with a form-text hint: "RtuOverTcp = talk raw RTU frames to a serial→Ethernet gateway; must match the gateway's mode."

**Step 4 — run & expect PASS.** (AdminUI has no bUnit — the razor binding itself is proven live in Task 10; this model test is the reflection substitute per the AdminUI live-verify memory.)

**Step 5 — commit:**
`git commit -am "feat(modbus-rtu): add Transport selector to the AdminUI Modbus driver form

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`

---

## Task 9: Docker `rtu_over_tcp` fixture + RTU-over-TCP integration test

**Classification:** standard (new component / multi-file)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/rtu_over_tcp.json`
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusRtuOverTcpFixture.cs`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusRtuOverTcpTests.cs`
- Modify (only if the pymodbus RTU-framer confirmation fails): `Docker/Dockerfile` + `Docker/rtu_over_tcp_server.py`

**FIRST sub-step — confirm the unknown (design §8 #1):** verify the pymodbus simulator actually exposes the RTU framer on a TCP server. The checked-in `profiles/standard.json` already sets `server_list.srv.framer: "socket"`, so the knob exists; the RTU profile sets `"framer": "rtu"` (comm stays `"tcp"`). Bring it up and probe with the new driver:
```bash
lmxopcua-fix sync modbus
lmxopcua-fix up modbus rtu_over_tcp
# then run the integration test (below) against MODBUS_RTU_SIM_ENDPOINT=10.100.0.35:5021
```
If pymodbus 3.13's simulator does **not** honour `framer=rtu` on a TCP server (garbled/MBAP-framed replies), **fall back** to a stdlib `rtu_over_tcp_server.py` modelled on the existing `exception_injector.py` (same asyncio TCP-server shape, but frame with `[addr][PDU][CRC-16]` via a Python CRC-16/MODBUS instead of the MBAP header) + a `COPY rtu_over_tcp_server.py` line in the Dockerfile and a `command:` override on the service. Record which path was taken in the test-class summary.

Fixture details: bind host port **`5021`** (NOT the shared `:5020` — the `rtu_over_tcp` service must co-run with `standard` and sidestep the shared-port stale-container trap). Add `labels: { project: lmxopcua }` per the program fixture convention (existing services carry none — `lmxopcua-fix sync` normally owns the label host-side, but add it in-file for this service so it is discoverable). `ModbusRtuOverTcpFixture` mirrors `ModbusSimulatorFixture` but reads `MODBUS_RTU_SIM_ENDPOINT` (default `10.100.0.35:5021`) with the same skip-clean pattern.

**Step 1 — failing test** (`ModbusRtuOverTcpTests.cs` — read + write round-trip over RTU-over-TCP):
```csharp
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;

[Collection(ModbusRtuOverTcpCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "RtuOverTcp")]
public sealed class ModbusRtuOverTcpTests(ModbusRtuOverTcpFixture sim)
{
    [Fact]
    public async Task Reads_a_seeded_holding_register_over_rtu_framing()
    {
        if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
        var opts = new ModbusDriverOptions
        {
            Host = sim.Host, Port = sim.Port, UnitId = 1,
            Transport = ModbusTransportMode.RtuOverTcp,
            Timeout = TimeSpan.FromSeconds(2),
            Probe = new ModbusProbeOptions { Enabled = false },
            RawTags = ModbusRawTags.Entries([
                new ModbusTagDefinition("HR5", ModbusRegion.HoldingRegisters, Address: 5,
                    DataType: ModbusDataType.UInt16, Writable: false),
            ]),
        };
        await using var driver = new ModbusDriver(opts, "modbus-rtu-int");
        await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);

        var results = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken);

        results[0].StatusCode.ShouldBe(0u);       // Good
        results[0].Value.ShouldBe((ushort)5);     // HR[5] seeded = address-as-value
    }

    [Fact]
    public async Task Writes_and_reads_back_a_holding_register_over_rtu_framing()
    {
        if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
        var opts = new ModbusDriverOptions
        {
            Host = sim.Host, Port = sim.Port, UnitId = 1,
            Transport = ModbusTransportMode.RtuOverTcp,
            Timeout = TimeSpan.FromSeconds(2),
            Probe = new ModbusProbeOptions { Enabled = false },
            RawTags = ModbusRawTags.Entries([
                new ModbusTagDefinition("HR200", ModbusRegion.HoldingRegisters, Address: 200,
                    DataType: ModbusDataType.UInt16, Writable: true),
            ]),
        };
        await using var driver = new ModbusDriver(opts, "modbus-rtu-int-w");
        await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);

        var writes = await driver.WriteAsync([new WriteRequest("HR200", (ushort)4242)],
            TestContext.Current.CancellationToken);
        writes[0].StatusCode.ShouldBe(0u);

        var back = await driver.ReadAsync(["HR200"], TestContext.Current.CancellationToken);
        back[0].Value.ShouldBe((ushort)4242);
    }
}
```
(Seed `rtu_over_tcp.json` with the same `HR[0..31]=address-as-value` + `HR[200..209]=scratch` layout as `standard.json` so these addresses resolve.)

**Step 2 — run & expect FAIL / SKIP** without the fixture, then bring it up:
```bash
lmxopcua-fix sync modbus && lmxopcua-fix up modbus rtu_over_tcp
MODBUS_RTU_SIM_ENDPOINT=10.100.0.35:5021 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests --filter "FullyQualifiedName~ModbusRtuOverTcpTests"
```

**Step 3 — minimal impl:** the `rtu_over_tcp.json` profile + compose service (+ stdlib server fallback only if the framer confirmation failed) + the fixture class. Iterate until both facts hold on the wire.

**Step 4 — run & expect PASS** against the live fixture.

**Step 5 — commit:**
`git commit -am "test(modbus-rtu): add rtu_over_tcp pymodbus fixture + RTU-over-TCP integration tests

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`

---

## Task 10: Live `/run` verification on docker-dev

**Classification:** standard (live gate)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify (docs only): `docs/plans/2026-07-24-driver-expansion-tracking.md` (record Wave-1 Modbus-RTU live-gate result)

No new code — the live-verify discipline (program §3.1) proves the Razor binding + deploy path that unit tests can't. Against the local docker-dev rig (AdminUI `http://localhost:9200`, login disabled — drive it yourself; do not wait for the user to sign in):

1. Bring up the `rtu_over_tcp` fixture (`lmxopcua-fix up modbus rtu_over_tcp`, host `:5021`).
2. In `/raw`, author a Modbus device pointing Host/Port at the docker host `:5021`, and set the driver **Transport = RtuOverTcp** in the driver-config modal (the Task 8 selector). Run **Test Connect** — expect green (the probe now runs FC03 over RTU framing).
3. Author a holding-register raw tag (e.g. `HR5`), reference it into a UNS equipment, and deploy.
4. Read it back via Client.CLI against the local server, or the `/uns` value panel:
   `dotnet run --project src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI -- read -u opc.tcp://localhost:4840 -n "ns=2;s=<RawPath-of-HR5>"`
   — expect Good quality + the seeded value.
5. Write to a writable register (`HR200`) via Client.CLI `write` and confirm it round-trips.
6. Record PASS/FAIL + evidence in the tracking doc.

**Step 5 — commit:**
`git commit -am "docs(modbus-rtu): record Wave-1 RTU-over-TCP live /run gate result

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW"`

---

## Ordering summary

Framer/CRC unit layer (Tasks 0–2) → transport (Tasks 3–4) → factory + config + driver/probe wiring (Tasks 5–7) → AdminUI selector (Task 8) → docker integration fixture (Task 9) → live `/run` (Task 10). Tasks 1 & 3 can run alongside Task 0; Task 5 alongside Task 6; Task 7 alongside Task 8. Commit after every task. DRY: `ModbusRtuFraming` and `ModbusSocketLifecycle` are each written once and composed; no codec/planner/health code is touched. YAGNI: no direct-serial, no ASCII, no per-tag config, no `BuildSlaveHostName` change.
