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"; }