diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml
index d083ddc9..9ce9bb17 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml
@@ -78,6 +78,30 @@ services:
"--json_file", "/fixtures/s7_1500.json"
]
+ # RTU-over-TCP profile. Same pymodbus simulator, but the server is framed
+ # RTU (framer=rtu) instead of socket/MBAP — this is what a serial→Ethernet
+ # gateway presents. Binds :5021 (NOT the shared :5020) so it co-runs with
+ # the `standard` profile: two families, two ports, no conflict. Serves
+ # rtu_over_tcp.json (byte-for-byte standard.json + framer/port swap).
+ rtu_over_tcp:
+ profiles: ["rtu_over_tcp"]
+ image: otopcua-pymodbus:3.13.0
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: otopcua-pymodbus-rtu_over_tcp
+ labels:
+ project: lmxopcua
+ restart: "no"
+ ports:
+ - "5021:5021"
+ command: [
+ "pymodbus.simulator",
+ "--modbus_server", "srv",
+ "--modbus_device", "dev",
+ "--json_file", "/fixtures/rtu_over_tcp.json"
+ ]
+
# Exception-injection profile. Runs the standalone pure-stdlib Modbus/TCP
# server shipped as exception_injector.py instead of the pymodbus
# simulator — pymodbus naturally emits only exception codes 02 + 03, and
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/rtu_over_tcp.json b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/rtu_over_tcp.json
new file mode 100644
index 00000000..443e687d
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/profiles/rtu_over_tcp.json
@@ -0,0 +1,97 @@
+{
+ "_comment": "rtu_over_tcp.json — RTU-over-TCP Modbus server for the integration suite (RTU framing tunnelled over a socket, as a serial→Ethernet gateway presents it). Byte-for-byte standard.json EXCEPT the server block: framer=\"rtu\" (not \"socket\") + port 5021 (not 5020), so it co-runs with the standard profile on :5020. pymodbus 3.13's simulator honors framer=rtu on a TCP server — confirmed on the wire (controller spike, 2026-07-24): raw RTU FC03 read of HR[5] returned `01 03 02 00 05 78 47`, a pure RTU frame with no MBAP header. Layout is identical to standard.json: HR[0..31]=address-as-value, HR[100]=auto-increment, HR[200..209]=scratch, coils 1024..1055=alternating, coils 1100..1109=scratch. NOTE: pymodbus rejects unknown keys at device-list / setup level; explanatory comments live in the README + git history.",
+
+ "server_list": {
+ "srv": {
+ "comm": "tcp",
+ "host": "0.0.0.0",
+ "port": 5021,
+ "framer": "rtu",
+ "device_id": 1
+ }
+ },
+
+ "device_list": {
+ "dev": {
+ "setup": {
+ "co size": 2048,
+ "di size": 2048,
+ "hr size": 2048,
+ "ir size": 2048,
+ "shared blocks": true,
+ "type exception": false,
+ "defaults": {
+ "value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "},
+ "action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null}
+ }
+ },
+ "invalid": [],
+ "write": [
+ [0, 31],
+ [100, 100],
+ [200, 209],
+ [1024, 1055],
+ [1100, 1109]
+ ],
+
+ "uint16": [
+ {"addr": 0, "value": 0}, {"addr": 1, "value": 1},
+ {"addr": 2, "value": 2}, {"addr": 3, "value": 3},
+ {"addr": 4, "value": 4}, {"addr": 5, "value": 5},
+ {"addr": 6, "value": 6}, {"addr": 7, "value": 7},
+ {"addr": 8, "value": 8}, {"addr": 9, "value": 9},
+ {"addr": 10, "value": 10}, {"addr": 11, "value": 11},
+ {"addr": 12, "value": 12}, {"addr": 13, "value": 13},
+ {"addr": 14, "value": 14}, {"addr": 15, "value": 15},
+ {"addr": 16, "value": 16}, {"addr": 17, "value": 17},
+ {"addr": 18, "value": 18}, {"addr": 19, "value": 19},
+ {"addr": 20, "value": 20}, {"addr": 21, "value": 21},
+ {"addr": 22, "value": 22}, {"addr": 23, "value": 23},
+ {"addr": 24, "value": 24}, {"addr": 25, "value": 25},
+ {"addr": 26, "value": 26}, {"addr": 27, "value": 27},
+ {"addr": 28, "value": 28}, {"addr": 29, "value": 29},
+ {"addr": 30, "value": 30}, {"addr": 31, "value": 31},
+
+ {"addr": 100, "value": 0,
+ "action": "increment",
+ "parameters": {"minval": 0, "maxval": 65535}},
+
+ {"addr": 200, "value": 0}, {"addr": 201, "value": 0},
+ {"addr": 202, "value": 0}, {"addr": 203, "value": 0},
+ {"addr": 204, "value": 0}, {"addr": 205, "value": 0},
+ {"addr": 206, "value": 0}, {"addr": 207, "value": 0},
+ {"addr": 208, "value": 0}, {"addr": 209, "value": 0}
+ ],
+
+ "bits": [
+ {"addr": 1024, "value": 1}, {"addr": 1025, "value": 0},
+ {"addr": 1026, "value": 1}, {"addr": 1027, "value": 0},
+ {"addr": 1028, "value": 1}, {"addr": 1029, "value": 0},
+ {"addr": 1030, "value": 1}, {"addr": 1031, "value": 0},
+ {"addr": 1032, "value": 1}, {"addr": 1033, "value": 0},
+ {"addr": 1034, "value": 1}, {"addr": 1035, "value": 0},
+ {"addr": 1036, "value": 1}, {"addr": 1037, "value": 0},
+ {"addr": 1038, "value": 1}, {"addr": 1039, "value": 0},
+ {"addr": 1040, "value": 1}, {"addr": 1041, "value": 0},
+ {"addr": 1042, "value": 1}, {"addr": 1043, "value": 0},
+ {"addr": 1044, "value": 1}, {"addr": 1045, "value": 0},
+ {"addr": 1046, "value": 1}, {"addr": 1047, "value": 0},
+ {"addr": 1048, "value": 1}, {"addr": 1049, "value": 0},
+ {"addr": 1050, "value": 1}, {"addr": 1051, "value": 0},
+ {"addr": 1052, "value": 1}, {"addr": 1053, "value": 0},
+ {"addr": 1054, "value": 1}, {"addr": 1055, "value": 0},
+
+ {"addr": 1100, "value": 0}, {"addr": 1101, "value": 0},
+ {"addr": 1102, "value": 0}, {"addr": 1103, "value": 0},
+ {"addr": 1104, "value": 0}, {"addr": 1105, "value": 0},
+ {"addr": 1106, "value": 0}, {"addr": 1107, "value": 0},
+ {"addr": 1108, "value": 0}, {"addr": 1109, "value": 0}
+ ],
+
+ "uint32": [],
+ "float32": [],
+ "string": [],
+ "repeat": []
+ }
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusRtuOverTcpFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusRtuOverTcpFixture.cs
new file mode 100644
index 00000000..a00748d4
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusRtuOverTcpFixture.cs
@@ -0,0 +1,82 @@
+using System.Net.Sockets;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
+
+///
+/// Reachability probe for the RTU-over-TCP Modbus simulator (pymodbus in Docker with
+/// framer=rtu, see Docker/docker-compose.yml profile rtu_over_tcp) or a
+/// real serial→Ethernet Modbus gateway. Mirrors but reads
+/// MODBUS_RTU_SIM_ENDPOINT (default 10.100.0.35:5021 — the shared Docker host, a
+/// distinct port from the standard profile's :5020 so the two fixtures co-run). TCP-connects
+/// once at fixture construction; each test checks and calls
+/// Assert.Skip when the endpoint was unreachable, so a dev box without a running simulator
+/// still passes `dotnet test` cleanly.
+///
+///
+/// Same one-shot-probe discipline as : the probe socket is
+/// not held for the life of the fixture (tests open their own
+/// against the same endpoint), and the fixture is a collection fixture so the probe runs once per
+/// session rather than per test.
+///
+public sealed class ModbusRtuOverTcpFixture : IAsyncDisposable
+{
+ // :5021 (not the standard profile's :5020) so the rtu_over_tcp container can co-run with the
+ // standard container on the shared Docker host. 10.100.0.35 = the shared Docker host (see
+ // CLAUDE.md "Docker Workflow"). Override with MODBUS_RTU_SIM_ENDPOINT to point at a real
+ // serial→Ethernet Modbus gateway, or a locally-running container.
+ private const string DefaultEndpoint = "10.100.0.35:5021";
+ private const string EndpointEnvVar = "MODBUS_RTU_SIM_ENDPOINT";
+
+ /// Gets the host address of the RTU-over-TCP Modbus simulator.
+ public string Host { get; }
+
+ /// Gets the port of the RTU-over-TCP Modbus simulator.
+ public int Port { get; }
+
+ /// Gets the skip reason if the simulator is unreachable; otherwise null.
+ public string? SkipReason { get; }
+
+ /// Initializes a new instance of the class.
+ public ModbusRtuOverTcpFixture()
+ {
+ var raw = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint;
+ var parts = raw.Split(':', 2);
+ Host = parts[0];
+ Port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : 502;
+
+ try
+ {
+ // Force IPv4 on the probe — pymodbus binds 0.0.0.0 (IPv4 only) while .NET default-resolves
+ // "localhost" → IPv6 ::1 first and only then tries IPv4, surfacing as a 2s timeout under
+ // .NET 10. Resolving + dialing explicit IPv4 sidesteps the dual-stack ordering. (Same
+ // rationale as ModbusSimulatorFixture.)
+ using var client = new TcpClient(System.Net.Sockets.AddressFamily.InterNetwork);
+ var task = client.ConnectAsync(
+ System.Net.Dns.GetHostAddresses(Host)
+ .FirstOrDefault(a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
+ ?? System.Net.IPAddress.Loopback,
+ Port);
+ if (!task.Wait(TimeSpan.FromSeconds(2)) || !client.Connected)
+ {
+ SkipReason = $"RTU-over-TCP Modbus simulator at {Host}:{Port} did not accept a TCP connection within 2s. " +
+ $"Start the pymodbus Docker container (docker compose -f Docker/docker-compose.yml --profile rtu_over_tcp up -d --build) " +
+ $"or override {EndpointEnvVar}, then re-run.";
+ }
+ }
+ catch (Exception ex)
+ {
+ SkipReason = $"RTU-over-TCP Modbus simulator at {Host}:{Port} unreachable: {ex.GetType().Name}: {ex.Message}. " +
+ $"Start the pymodbus Docker container (docker compose -f Docker/docker-compose.yml --profile rtu_over_tcp up -d --build) " +
+ $"or override {EndpointEnvVar}, then re-run.";
+ }
+ }
+
+ ///
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+}
+
+[Xunit.CollectionDefinition(Name)]
+public sealed class ModbusRtuOverTcpCollection : Xunit.ICollectionFixture
+{
+ public const string Name = "ModbusRtuOverTcp";
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusRtuOverTcpTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusRtuOverTcpTests.cs
new file mode 100644
index 00000000..08448777
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusRtuOverTcpTests.cs
@@ -0,0 +1,94 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
+
+///
+/// End-to-end round-trip against the rtu_over_tcp.json pymodbus profile (or a real
+/// serial→Ethernet Modbus gateway when MODBUS_RTU_SIM_ENDPOINT points at one), driving the
+/// full + real stack with
+/// . Proves the driver reads a seeded holding register
+/// and writes+reads-back a scratch register when the wire carries RTU framing
+/// ([addr][PDU][CRC-16], no MBAP header) rather than Modbus/TCP.
+///
+///
+/// pymodbus 3.13's simulator honors framer=rtu on a TCP server — confirmed on the wire
+/// (controller spike, 2026-07-24): a raw RTU FC03 read of HR[5] returned
+/// 01 03 02 00 05 78 47, a pure RTU frame with no MBAP header (data 0x0005 = 5). So the
+/// fixture is the plain pymodbus simulator with framer/port swapped — no stdlib fallback server.
+///
+[Collection(ModbusRtuOverTcpCollection.Name)]
+[Trait("Category", "Integration")]
+[Trait("Device", "RtuOverTcp")]
+public sealed class ModbusRtuOverTcpTests(ModbusRtuOverTcpFixture sim)
+{
+ /// Reads a seeded holding register (HR[5]=5) over RTU-over-TCP framing.
+ [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(
+ Name: "HR5",
+ Region: ModbusRegion.HoldingRegisters,
+ Address: 5,
+ DataType: ModbusDataType.UInt16,
+ Writable: false),
+ ]),
+ };
+ await using var driver = new ModbusDriver(opts, driverInstanceId: "modbus-rtu-int");
+ await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
+
+ var results = await driver.ReadAsync(["HR5"], TestContext.Current.CancellationToken);
+
+ results.Count.ShouldBe(1);
+ results[0].StatusCode.ShouldBe(0u); // Good
+ results[0].Value.ShouldBe((ushort)5); // HR[5] seeded = address-as-value
+ }
+
+ /// Writes then reads back a scratch holding register (HR[200]) over RTU-over-TCP framing.
+ [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(
+ Name: "HR200",
+ Region: ModbusRegion.HoldingRegisters,
+ Address: 200,
+ DataType: ModbusDataType.UInt16,
+ Writable: true),
+ ]),
+ };
+ await using var driver = new ModbusDriver(opts, driverInstanceId: "modbus-rtu-int-w");
+ await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
+
+ var writes = await driver.WriteAsync(
+ [new WriteRequest("HR200", (ushort)4242)],
+ TestContext.Current.CancellationToken);
+ writes.Count.ShouldBe(1);
+ writes[0].StatusCode.ShouldBe(0u);
+
+ var back = await driver.ReadAsync(["HR200"], TestContext.Current.CancellationToken);
+ back.Count.ShouldBe(1);
+ back[0].StatusCode.ShouldBe(0u);
+ back[0].Value.ShouldBe((ushort)4242);
+ }
+}