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": [],