Three substantive issues caught + fixed during the validation pass: 1. pymodbus rejects unknown keys at device-list / setup level. My PR 43 commit had `_layout_note`, `_uint16_layout`, `_bits_layout`, `_write_note` device-level JSON-comment fields that crashed pymodbus startup with `INVALID key in setup`. Removed all device-level _* fields. Inline `_quirk` keys WITHIN individual register entries are tolerated by pymodbus 3.13.0 — kept those in dl205.json since they document the byte math per quirk and the README + git history aren't enough context for a hand-author reading raw integer values. Documented the constraint in the top-level _comment of each profile. 2. pymodbus rejects sweeping `write` ranges that include any cell not assigned a type. My initial standard.json had `write: [[0, 2047]]` but only seeded HR[0..31] + HR[100] + HR[200..209] + bits[1024..1109] — pymodbus blew up on cell 32 (gap between HR[31] and HR[100]). Fixed by listing per-block write ranges that exactly mirror the seeded ranges. Same fix in dl205.json (was `[[0, 16383]]`). 3. pymodbus simulator stores all 4 standard Modbus tables in ONE underlying cell array — each cell can only be typed once (BITS or UINT16, not both). My initial standard.json had `bits[0..31]` AND `uint16[0..31]` overlapping at the same addresses; pymodbus crashed with `ERROR "uint16" <Cell> used`. Fixed by relocating coils to address 1024+, well clear of the uint16 entries at 0..209. Documented the layout constraint in the standard.json top-level _comment. Substantive driver bug fixed: ModbusTcpTransport.ConnectAsync was using `new TcpClient()` (default constructor — dual-stack, IPv6 first) then `ConnectAsync(host, port)` with the user's hostname. .NET's TcpClient default-resolves "localhost" to ::1 first, fails to connect to pymodbus (which binds 0.0.0.0 IPv4-only), and only then retries IPv4 — the failure surfaces as the entire ConnectAsync timeout (2s by default) before the IPv4 attempt even starts. PR 30's smoke test silently SKIPPED because the fixture's TCP probe hit the same dual-stack ordering and timed out. Both fixed: ModbusSimulatorFixture probe now resolves Dns.GetHostAddresses, prefers AddressFamily.InterNetwork, dials IPv4 explicitly. ModbusTcpTransport does the same — resolves first, prefers IPv4, falls back to whatever Dns returns (handles IPv6-only hosts in the future). This is a real production-readiness fix because most Modbus PLCs are IPv4-only — a generic dual-stack TcpClient would burn the entire connect timeout against any IPv4-only PLC, masquerading as a connection failure when the PLC is actually fine. Smoke-test address shifted HR[100] -> HR[200]. Standard.json's HR[100] is the auto-incrementing register that drives subscribe-and-receive tests, so write-then-read against it would race the increment. HR[200] is the first cell of a writable scratch range present in BOTH simulator profiles. DL205Profile.cs xml-doc updated to explain the shift; tag name "DL205_Smoke_HReg100" -> "Smoke_HReg200" + smoke test references updated. dl205.json gains a matching scratch HR[200..209] range so the smoke test runs identically against either profile. Validation matrix: - standard.json boot: clean (TCP 5020 listening within ~3s of pymodbus.simulator launch). - dl205.json boot: clean. - pymodbus client direct FC06 to HR[200]=1234 + FC03 read: round-trip OK. - raw-bytes PowerShell TcpClient FC06 + 12-byte response: matches FC06 spec (echo of address + value). - DL205SmokeTest against standard.json: 1/1 pass (was failing as 'BadInternalError' due to the dual-stack timeout + tag-name typo — both fixed). - DL205SmokeTest against dl205.json: 1/1 pass. - Modbus.Tests Unit suite: 52/52 pass — dual-stack transport fix is non-breaking. - Solution build clean. Memory + future-PR setup: pymodbus install + activation pattern is now bullet-pointed at the top of Pymodbus/README.md so future PRs (the per-quirk DL205_<behavior> tests in PR 44+) don't have to repeat the trial-and-error of getting the simulator + integration tests cooperating. The three bugs above are documented inline in the JSON profiles + ModbusTcpTransport so they don't bite again. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
54 lines
2.6 KiB
C#
54 lines
2.6 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
|
|
|
|
/// <summary>
|
|
/// End-to-end smoke against the DL205 ModbusPal profile (or a real DL205 when
|
|
/// <c>MODBUS_SIM_ENDPOINT</c> points at one). Drives the full <see cref="ModbusDriver"/>
|
|
/// + real <see cref="ModbusTcpTransport"/> stack — no fake transport. Success proves the
|
|
/// driver can initialize against the simulator, write a known value, and read it back
|
|
/// with the correct status and value, which is the baseline every device-quirk test
|
|
/// builds on.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Device-specific quirk tests (word order, max-register, register-zero access, exception
|
|
/// code translation, etc.) land as separate test classes in this directory as each quirk
|
|
/// is validated in ModbusPal. Keep this smoke test deliberately narrow — any deviation
|
|
/// the driver hits beyond "happy-path FC16 + FC03 round-trip" belongs in its own named
|
|
/// test so filtering by device class (<c>--filter DisplayName~DL205</c>) surfaces the
|
|
/// quirk-specific failure mode.
|
|
/// </remarks>
|
|
[Collection(ModbusSimulatorCollection.Name)]
|
|
[Trait("Category", "Integration")]
|
|
[Trait("Device", "DL205")]
|
|
public sealed class DL205SmokeTests(ModbusSimulatorFixture sim)
|
|
{
|
|
[Fact]
|
|
public async Task DL205_roundtrip_write_then_read_of_holding_register()
|
|
{
|
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
|
|
|
var options = DL205Profile.BuildOptions(sim.Host, sim.Port);
|
|
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-smoke");
|
|
await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
|
|
|
|
// Write first so the test is self-contained — ModbusPal's default register bank is
|
|
// 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: "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(
|
|
["Smoke_HReg200"],
|
|
TestContext.Current.CancellationToken);
|
|
readResults.Count.ShouldBe(1);
|
|
readResults[0].StatusCode.ShouldBe(0u);
|
|
readResults[0].Value.ShouldBe((short)DL205Profile.SmokeHoldingValue);
|
|
}
|
|
}
|