650e27075f
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
83 lines
4.3 KiB
C#
83 lines
4.3 KiB
C#
using System.Net.Sockets;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Reachability probe for the RTU-over-TCP Modbus simulator (pymodbus in Docker with
|
|
/// <c>framer=rtu</c>, see <c>Docker/docker-compose.yml</c> profile <c>rtu_over_tcp</c>) or a
|
|
/// real serial→Ethernet Modbus gateway. Mirrors <see cref="ModbusSimulatorFixture"/> but reads
|
|
/// <c>MODBUS_RTU_SIM_ENDPOINT</c> (default <c>10.100.0.35:5021</c> — the shared Docker host, a
|
|
/// distinct port from the standard profile's <c>:5020</c> so the two fixtures co-run). TCP-connects
|
|
/// once at fixture construction; each test checks <see cref="SkipReason"/> and calls
|
|
/// <c>Assert.Skip</c> when the endpoint was unreachable, so a dev box without a running simulator
|
|
/// still passes `dotnet test` cleanly.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Same one-shot-probe discipline as <see cref="ModbusSimulatorFixture"/>: the probe socket is
|
|
/// not held for the life of the fixture (tests open their own <see cref="ModbusRtuOverTcpTransport"/>
|
|
/// against the same endpoint), and the fixture is a collection fixture so the probe runs once per
|
|
/// session rather than per test.
|
|
/// </remarks>
|
|
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";
|
|
|
|
/// <summary>Gets the host address of the RTU-over-TCP Modbus simulator.</summary>
|
|
public string Host { get; }
|
|
|
|
/// <summary>Gets the port of the RTU-over-TCP Modbus simulator.</summary>
|
|
public int Port { get; }
|
|
|
|
/// <summary>Gets the skip reason if the simulator is unreachable; otherwise null.</summary>
|
|
public string? SkipReason { get; }
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="ModbusRtuOverTcpFixture"/> class.</summary>
|
|
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.";
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
|
}
|
|
|
|
[Xunit.CollectionDefinition(Name)]
|
|
public sealed class ModbusRtuOverTcpCollection : Xunit.ICollectionFixture<ModbusRtuOverTcpFixture>
|
|
{
|
|
public const string Name = "ModbusRtuOverTcp";
|
|
}
|