Phase 3 PR 44 -- pymodbus validation + IPv4-explicit transport bugfix #43

Merged
dohertj2 merged 1 commits from phase-3-pr44-pymodbus-validation-fixes into v2 2026-04-18 21:39:25 -04:00
6 changed files with 110 additions and 50 deletions

View File

@@ -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();
}

View File

@@ -14,13 +14,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
/// </remarks>
public static class DL205Profile
{
/// <summary>Holding register the smoke test reads. Address 100 sidesteps the DL205
/// register-zero quirk (pending confirmation) — see modbus-test-plan.md.</summary>
public const ushort SmokeHoldingRegister = 100;
/// <summary>
/// Holding register the smoke test writes + reads. Address 200 is the first cell of the
/// scratch HR range in both <c>Pymodbus/standard.json</c> (HR[200..209] = 0) and
/// <c>Pymodbus/dl205.json</c> (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.
/// </summary>
public const ushort SmokeHoldingRegister = 200;
/// <summary>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.</summary>
/// <summary>Value the smoke test writes then reads back to assert round-trip integrity.</summary>
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,

View File

@@ -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);

View File

@@ -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. " +

View File

@@ -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_<behavior> 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},

View File

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