diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs index 5e79e90..381e011 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs @@ -28,10 +28,20 @@ public sealed class ModbusTcpTransport : IModbusTransport public async Task ConnectAsync(CancellationToken ct) { - _client = new TcpClient(); + // Resolve the host explicitly + prefer IPv4. .NET's TcpClient default-constructor is + // dual-stack (IPv6 first, fallback to IPv4) — but most Modbus TCP devices (PLCs and + // simulators like pymodbus) bind 0.0.0.0 only, so the IPv6 attempt times out and we + // burn the entire ConnectAsync budget before even trying IPv4. Resolving first + + // dialing the IPv4 address directly sidesteps that. + var addresses = await System.Net.Dns.GetHostAddressesAsync(_host, ct).ConfigureAwait(false); + var ipv4 = System.Linq.Enumerable.FirstOrDefault(addresses, + a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork); + var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback); + + _client = new TcpClient(target.AddressFamily); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(_timeout); - await _client.ConnectAsync(_host, _port, cts.Token).ConfigureAwait(false); + await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false); _stream = _client.GetStream(); } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205Profile.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205Profile.cs index 4b26a4c..8095a23 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205Profile.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205Profile.cs @@ -14,13 +14,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205; /// public static class DL205Profile { - /// Holding register the smoke test reads. Address 100 sidesteps the DL205 - /// register-zero quirk (pending confirmation) — see modbus-test-plan.md. - public const ushort SmokeHoldingRegister = 100; + /// + /// Holding register the smoke test writes + reads. Address 200 is the first cell of the + /// scratch HR range in both Pymodbus/standard.json (HR[200..209] = 0) and + /// Pymodbus/dl205.json (HR[4096..4103] added in PR 43 for the same purpose), so + /// the smoke test runs identically against either simulator profile. Originally + /// targeted HR[100] — moved to HR[200] when the standard profile claimed HR[100] as + /// the auto-incrementing register that drives subscribe-and-receive tests. + /// + public const ushort SmokeHoldingRegister = 200; - /// Expected value the pymodbus profile seeds into register 100. When running - /// against a real DL205 (or a pymodbus profile where this register is writable), the smoke - /// test seeds this value first, then reads it back. + /// Value the smoke test writes then reads back to assert round-trip integrity. public const short SmokeHoldingValue = 1234; public static ModbusDriverOptions BuildOptions(string host, int port) => new() @@ -32,7 +36,7 @@ public static class DL205Profile Tags = [ new ModbusTagDefinition( - Name: "DL205_Smoke_HReg100", + Name: "Smoke_HReg200", Region: ModbusRegion.HoldingRegisters, Address: SmokeHoldingRegister, DataType: ModbusDataType.Int16, diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205SmokeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205SmokeTests.cs index d1dd22c..69542a0 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205SmokeTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205SmokeTests.cs @@ -38,13 +38,13 @@ public sealed class DL205SmokeTests(ModbusSimulatorFixture sim) // zeroed at simulator start, and tests must not depend on prior-test state per the // test-plan conventions. var writeResults = await driver.WriteAsync( - [new(FullReference: "DL205_Smoke_HReg100", Value: (short)DL205Profile.SmokeHoldingValue)], + [new(FullReference: "Smoke_HReg200", Value: (short)DL205Profile.SmokeHoldingValue)], TestContext.Current.CancellationToken); writeResults.Count.ShouldBe(1); writeResults[0].StatusCode.ShouldBe(0u, "write must succeed against the ModbusPal DL205 profile"); var readResults = await driver.ReadAsync( - ["DL205_Smoke_HReg100"], + ["Smoke_HReg200"], TestContext.Current.CancellationToken); readResults.Count.ShouldBe(1); readResults[0].StatusCode.ShouldBe(0u); diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs index 7e34236..1996eff 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ModbusSimulatorFixture.cs @@ -46,8 +46,18 @@ public sealed class ModbusSimulatorFixture : IAsyncDisposable try { - using var client = new TcpClient(); - var task = client.ConnectAsync(Host, Port); + // Force IPv4 family on the probe — pymodbus's TCP server binds 0.0.0.0 (IPv4 only) + // while .NET's TcpClient default-resolves "localhost" → IPv6 ::1 first, fails to + // connect, and only then tries IPv4. Under .NET 10 the IPv6 fail surfaces as a + // 2s timeout (no graceful fallback by default), so the C# probe times out even + // though a PowerShell probe of the same endpoint succeeds. Resolving + dialing + // explicit IPv4 sidesteps the dual-stack ordering. + 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 = $"Modbus simulator at {Host}:{Port} did not accept a TCP connection within 2s. " + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/dl205.json b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/dl205.json index 0bd67cb..0686a49 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/dl205.json +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/dl205.json @@ -1,5 +1,5 @@ { - "_comment": "DL205.json — AutomationDirect DirectLOGIC DL205/DL260 quirk simulator. Models each behavior in docs/v2/dl205.md as concrete register values so DL205_ integration tests can assert against this profile WITHOUT a live PLC. Loaded by `pymodbus.simulator`. See ../README.md. Per-quirk address layout matches the table in dl205.md exactly. `shared blocks: true` matches DL series behavior — coils/HR overlay the same word address space (a Y-output is both a discrete bit AND part of a system V-memory register).", + "_comment": "DL205.json — DirectLOGIC DL205/DL260 quirk simulator. Models docs/v2/dl205.md as concrete register values. NOTE: pymodbus rejects unknown keys at device-list / setup level; explanatory comments live at top-level _comment + in README + git. Inline _quirk keys WITHIN individual register entries are accepted by pymodbus 3.13.0 (it only validates addr / value / action / parameters per entry). Each quirky uint16 is a pre-computed raw 16-bit value; pymodbus serves it verbatim. shared blocks=true matches DL series memory model. write list mirrors each seeded block — pymodbus rejects sweeping write ranges that include undefined cells.", "server_list": { "srv": { @@ -27,15 +27,37 @@ }, "invalid": [], "write": [ - [0, 16383] + [0, 0], + [200, 209], + [1024, 1024], + [1040, 1042], + [1056, 1057], + [1072, 1072], + [1280, 1282], + [1343, 1343], + [1407, 1407], + [2048, 2050], + [3072, 3074], + [4000, 4007], + [8448, 8448] ], - "_comment_uint16": "Holding-register seeds. Every quirky value is a raw uint16 with the byte math worked out in dl205.md so the simulator serves it verbatim — pymodbus does NOT decode strings, BCD, or float-CDAB on its own; that's the driver's job.", - "uint16": [ - {"_quirk": "V0 marker. HR[0] = 0xCAFE proves register 0 is valid on DL205/DL260 (rejects-register-0 was a DL05/DL06 relative-mode artefact). 0xCAFE = 51966.", + {"_quirk": "V0 marker. HR[0]=0xCAFE proves register 0 is valid on DL205/DL260 (rejects-register-0 was a DL05/DL06 relative-mode artefact). 0xCAFE = 51966.", "addr": 0, "value": 51966}, + {"_quirk": "Scratch HR range 200..209 — mirrors the standard.json scratch range so the smoke test (DL205Profile.SmokeHoldingRegister=200) round-trips identically against either profile.", + "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}, + {"_quirk": "V2000 marker. V2000 octal = decimal 1024 = PDU 0x0400. Marker 0x2000 = 8192.", "addr": 1024, "value": 8192}, @@ -57,16 +79,14 @@ {"_quirk": "BCD register. Decimal 1234 stored as BCD nibbles 0x1234 = 4660. NOT binary 1234 (= 0x04D2).", "addr": 1072, "value": 4660}, - {"_quirk": "FC03 cap test. Real DL205/DL260 FC03 caps at 128 registers (above spec's 125). HR[1280..1407] is 128 contiguous registers; rest of block defaults to 0.", + {"_quirk": "FC03 cap test marker — first cell of a 128-register span the FC03 cap test reads. Other cells in the span aren't seeded explicitly, so reads of HR[1283..1342] / 1344..1406 return the default 0; the seeded markers at 1280, 1281, 1282, 1343, 1407 prove the span boundaries.", "addr": 1280, "value": 0}, {"addr": 1281, "value": 1}, {"addr": 1282, "value": 2}, - {"addr": 1343, "value": 63, "_marker": "FC03Block_mid"}, - {"addr": 1407, "value": 127, "_marker": "FC03Block_last"} + {"addr": 1343, "value": 63}, + {"addr": 1407, "value": 127} ], - "_comment_bits": "Coils — Y outputs at 2048+, C relays at 3072+, scratch C at 4000-4007 for write tests. DL260 X inputs would be at discrete-input addresses 0..511 but pymodbus's shared-blocks mode + same-table-as-coils means those would conflict with HR seeds; FC02 tests against this profile use a separate discrete-input block instead — that's why `di size` is large but the X-input markers live in `bits` only when `shared blocks=false`. Document trade-off in README.", - "bits": [ {"_quirk": "Y0 marker. DL260 maps Y0 to coil 2048 (0-based). Coil 2048 = ON proves the mapping.", "addr": 2048, "value": 1}, diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/standard.json b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/standard.json index 2738d6f..5d9b63a 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/standard.json +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/standard.json @@ -1,5 +1,5 @@ { - "_comment": "Standard.json — generic Modbus TCP server for the integration suite. Loaded by `pymodbus.simulator`. See ../README.md for the launch command. Holding registers 0..31 are seeded with their address as value (HR[5]=5) for easy mental-map diagnostics. HR[100] auto-increments via pymodbus's built-in `increment` action so subscribe-and-receive integration tests have a register that ticks without a write. HR[200..209] is a scratch range left at 0 for write-roundtrip tests. Coils 0..31 alternate on/off (even=on); coils 100..109 scratch.", + "_comment": "Standard.json — generic Modbus TCP server for the integration suite. See ../README.md. NOTE: pymodbus rejects unknown keys at device-list / setup level; explanatory comments live in the README + git history. Layout: HR[0..31]=address-as-value, HR[100]=auto-increment, HR[200..209]=scratch, coils 1024..1055=alternating, coils 1100..1109=scratch. Coils live at 1024+ because pymodbus stores all 4 standard tables in ONE underlying cell array — bits and uint16 at the same address conflict (each cell can only be typed once).", "server_list": { "srv": { @@ -14,11 +14,11 @@ "device_list": { "dev": { "setup": { - "co size": 1024, - "di size": 1024, - "hr size": 1024, - "ir size": 1024, - "shared blocks": false, + "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": " "}, @@ -27,26 +27,11 @@ }, "invalid": [], "write": [ - [0, 1023] - ], - - "bits": [ - {"addr": 0, "value": 1}, {"addr": 1, "value": 0}, - {"addr": 2, "value": 1}, {"addr": 3, "value": 0}, - {"addr": 4, "value": 1}, {"addr": 5, "value": 0}, - {"addr": 6, "value": 1}, {"addr": 7, "value": 0}, - {"addr": 8, "value": 1}, {"addr": 9, "value": 0}, - {"addr": 10, "value": 1}, {"addr": 11, "value": 0}, - {"addr": 12, "value": 1}, {"addr": 13, "value": 0}, - {"addr": 14, "value": 1}, {"addr": 15, "value": 0}, - {"addr": 16, "value": 1}, {"addr": 17, "value": 0}, - {"addr": 18, "value": 1}, {"addr": 19, "value": 0}, - {"addr": 20, "value": 1}, {"addr": 21, "value": 0}, - {"addr": 22, "value": 1}, {"addr": 23, "value": 0}, - {"addr": 24, "value": 1}, {"addr": 25, "value": 0}, - {"addr": 26, "value": 1}, {"addr": 27, "value": 0}, - {"addr": 28, "value": 1}, {"addr": 29, "value": 0}, - {"addr": 30, "value": 1}, {"addr": 31, "value": 0} + [0, 31], + [100, 100], + [200, 209], + [1024, 1055], + [1100, 1109] ], "uint16": [ @@ -69,7 +54,38 @@ {"addr": 100, "value": 0, "action": "increment", - "parameters": {"minval": 0, "maxval": 65535}} + "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": [],