Add a living progress tracker (docs/plans/2026-07-24-driver-expansion-tracking.md)
for the driver-expansion program's Waves 0-2, linked from the program design doc's
document map. One row per deliverable pointing at design + implementation plan + status.
Add executable implementation plans (plan .md + co-located .tasks.json) for the four
Wave 1/2 drivers, each authored by writing-plans methodology off its design doc and
mirroring the relevant existing driver:
- Modbus RTU 11 tasks (extends Driver.Modbus; RTU-over-TCP, direct-serial descoped)
- SQL poll 22 tasks (ISqlDialect seam, SQL Server only in v1; SQLite + central-SQL fixtures)
- MTConnect Agent 23 tasks (browse free via Wave-0 seam; TrakHound-vs-hand-rolled = Task 0)
- MQTT/Sparkplug B 27 tasks (P1 plain MQTT 0-14 then P2 Sparkplug 15-26; MQTTnet-5 pinning spike = Task 0)
Wave 0 (universal browser) remains merged (056887d6) with its live gate open (#468).
All four new plans are design-only -> plan-ready; nothing built yet.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
40 KiB
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; theModbusRtuFramingbuilt here is byte-stream-agnostic so a future serial leg would reuse it unchanged. Do not number-squat anRtuenum member for it. - Modbus ASCII — a third framing (
:-delimited hex + LRC); would be anotherIModbusTransportif ever needed (design §2c). Not planned. - Per-tag
TagConfigchanges — none.ModbusTagDefinition/ModbusTagDtoalready carry the per-tagUnitIdoverride 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.BuildSlaveHostNamechange — NOT needed. It already emitshost: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 —
TransportMUST round-trip as a name string, never a number, on both the AdminUI serializer (ModbusDriverForm._jsonOptsalready carriesJsonStringEnumConverter) and the factory (DTO field typedstring?, parsed via the existingParseEnum<T>— mirrorFamily/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
_gateSemaphoreSlimin 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
/runbefore 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:
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:
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):
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:
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):
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):
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:
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:
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):
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):
[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:
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):
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:
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):
- Bring up the
rtu_over_tcpfixture (lmxopcua-fix up modbus rtu_over_tcp, host:5021). - 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). - Author a holding-register raw tag (e.g.
HR5), reference it into a UNS equipment, and deploy. - Read it back via Client.CLI against the local server, or the
/unsvalue 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. - Write to a writable register (
HR200) via Client.CLIwriteand confirm it round-trips. - 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.