# 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` — 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().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; /// /// Wire transport for a Modbus driver instance. = Modbus/TCP (MBAP + TxId); /// = RTU framing (address + CRC-16, no MBAP) tunnelled over a socket to /// a serial→Ethernet gateway. Default — existing configs omit the field. /// A direct-serial Rtu member is intentionally NOT reserved here (descoped 2026-07-15); /// do not number-squat it. /// public enum ModbusTransportMode { /// Modbus/TCP — 7-byte MBAP header + transaction id (the historical default). Tcp, /// RTU framing ([addr][PDU][CRC-16]) tunnelled over a socket to a serial→Ethernet gateway. 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)` (init `0xFFFF`, xor byte, 8× shift-right, xor `0xA001` on carry) and `static byte[] Append(ReadOnlySpan 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( 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( 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 pdu)` = `ModbusCrc.Append([unitId, ..pdu])`; `static async Task 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( 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( 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(), stall: true); await using var transport = ModbusRtuOverTcpTransport.ForTest(fake, TimeSpan.FromMilliseconds(200)); await Should.ThrowAsync( 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(); [Fact] public void RtuOverTcp_mode_builds_rtu_transport() => ModbusTransportFactory.Create(new ModbusDriverOptions { Transport = ModbusTransportMode.RtuOverTcp }) .ShouldBeOfType(); [Fact] public void Default_options_build_tcp_transport() => ModbusTransportFactory.Create(new ModbusDriverOptions()) .ShouldBeOfType(); } ``` (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(...)` 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(dto.Transport, "", 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)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(); [Fact] public void Default_closure_builds_tcp_transport_for_Tcp() => BuildDefaultTransport(new ModbusDriverOptions()) .ShouldBeOfType(); } ``` (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` `` 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(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 `` looping `Enum.GetValues()` 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="` — 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.