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); + } +}