From 9892a0253d0500e4e7acdf6bbe26f3fa01b29bfc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 22:25:13 -0400 Subject: [PATCH] =?UTF-8?q?Phase=203=20PR=2051=20--=20DL260=20X-input=20FC?= =?UTF-8?q?02=20discrete-input=20mapping=20end-to-end=20test.=20Integratio?= =?UTF-8?q?n=20test=20DL205XInputTests=20reads=20FC02=20at=20the=20DirectL?= =?UTF-8?q?ogicAddress.XInputToDiscrete-resolved=20address=20and=20asserts?= =?UTF-8?q?=20two=20behaviors=20against=20the=20dl205.json=20pymodbus=20pr?= =?UTF-8?q?ofile:=20(1)=20X20=20octal=20(=3Ddecimal=2016=20=3D=20Modbus=20?= =?UTF-8?q?DI=2016)=20reads=20ON,=20proving=20the=20helper=20correctly=20o?= =?UTF-8?q?ctal-parses=20the=20trailing=20number=20and=20adds=20it=20to=20?= =?UTF-8?q?the=200=20base;=20(2)=20X21=20octal=20reads=20OFF=20(not=20exce?= =?UTF-8?q?ption)=20--=20per=20docs/v2/dl205.md=20=C2=A7I/O-mapping,=20're?= =?UTF-8?q?ading=20a=20non-populated=20X=20input=20returns=20zero,=20not?= =?UTF-8?q?=20an=20exception'=20on=20DL260,=20because=20the=20CPU=20sizes?= =?UTF-8?q?=20the=20discrete-input=20table=20to=20the=20configured=20I/O?= =?UTF-8?q?=20not=20the=20installed=20hardware.=20Pymodbus=20models=20this?= =?UTF-8?q?=20by=20returning=20the=20default=200=20value=20for=20any=20DI?= =?UTF-8?q?=20bit=20in=20the=20configured=20'di=20size'=20range=20that=20w?= =?UTF-8?q?asn't=20explicitly=20seeded,=20matching=20real=20DL260=20behavi?= =?UTF-8?q?our.=20Test=20uses=20X20=20rather=20than=20X0=20to=20sidestep?= =?UTF-8?q?=20a=20shared-blocks=20conflict:=20pymodbus=20places=20FC01/FC0?= =?UTF-8?q?2=20bit-address=200..15=20into=20cell=200,=20but=20cell=200=20i?= =?UTF-8?q?s=20already=20uint16-typed=20(V0=20marker=20=3D=200xCAFE)=20per?= =?UTF-8?q?=20the=20register-zero=20quirk=20test,=20and=20shared-blocks=20?= =?UTF-8?q?semantics=20allow=20only=20one=20type=20per=20cell.=20X20=20oct?= =?UTF-8?q?al=20=3D=20DI=2016=20lands=20in=20cell=201=20which=20is=20free,?= =?UTF-8?q?=20so=20both=20the=20V0=20quirk=20AND=20the=20X-input=20quirk?= =?UTF-8?q?=20can=20coexist=20in=20one=20profile.=20dl205.json:=20bits=20c?= =?UTF-8?q?ell=201=20seeded=20value=3D9=20(bits=200=20and=203=20set=20->?= =?UTF-8?q?=20X20,=20X23=20octal=20=3D=20ON),=20write-range=20extended=20t?= =?UTF-8?q?o=20include=20cell=201=20(though=20X-inputs=20are=20read-only;?= =?UTF-8?q?=20the=20write-range=20entry=20is=20required=20by=20pymodbus=20?= =?UTF-8?q?for=20ANY=20cell=20referenced=20in=20a=20bits=20section=20even?= =?UTF-8?q?=20if=20only=20reads=20are=20expected=20--=20pymodbus=20validat?= =?UTF-8?q?es=20write-access=20uniformly).=2010/10=20DL205=20integration?= =?UTF-8?q?=20tests=20pass=20with=20MODBUS=5FSIM=5FPROFILE=3Ddl205.=20No?= =?UTF-8?q?=20driver=20code=20changes=20--=20the=20XInputToDiscrete=20help?= =?UTF-8?q?er=20+=20FC02=20read=20path=20already=20landed=20in=20PRs=2050?= =?UTF-8?q?=20and=2021=20respectively.=20This=20PR=20closes=20the=20integr?= =?UTF-8?q?ation-test=20gap=20that=20docs/v2/dl205.md=20called=20out=20und?= =?UTF-8?q?er=20test=20name=20DL205=5FXinput=5Funpopulated=5Freads=5Fas=5F?= =?UTF-8?q?zero.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DL205/DL205XInputTests.cs | 71 +++++++++++++++++++ .../Pymodbus/dl205.json | 4 ++ 2 files changed, 75 insertions(+) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205XInputTests.cs diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205XInputTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205XInputTests.cs new file mode 100644 index 0000000..5073113 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205XInputTests.cs @@ -0,0 +1,71 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205; + +/// +/// Verifies the DL260 X-input discrete-input mapping against the dl205.json +/// pymodbus profile. X-inputs are FC02 discrete-input-only (Modbus doesn't allow writes +/// to discrete inputs), and the DirectLOGIC convention is X0 → DI 0 with octal offsets +/// for subsequent addresses. The sim seeds X20 octal (= DI 16) = ON so the test can +/// prove the helper routes through to the right cell. +/// +/// +/// X0 / X1 / …X17 octal all share cell 0 (DI 0-15 → cell 0 bits 0-15) which conflicts +/// with the V0 uint16 marker; we can't seed both types at cell 0 under shared-blocks +/// semantics. So the test uses X20 octal (first address beyond the cell-0 boundary) which +/// lands cleanly at cell 1 bit 0 and leaves the V0 register-zero quirk intact. +/// +[Collection(ModbusSimulatorCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Device", "DL205")] +public sealed class DL205XInputTests(ModbusSimulatorFixture sim) +{ + [Fact] + public async Task DL260_X20_octal_maps_to_DiscreteInput_16_and_reads_ON() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205", + StringComparison.OrdinalIgnoreCase)) + { + Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping."); + } + + // X20 octal = decimal 16 = DI 16 per the DL260 convention (X-inputs start at DI 0). + var di = DirectLogicAddress.XInputToDiscrete("X20"); + di.ShouldBe((ushort)16); + + var options = BuildOptions(sim, [ + new ModbusTagDefinition("DL260_X20", + ModbusRegion.DiscreteInputs, Address: di, + DataType: ModbusDataType.Bool, Writable: false), + // Unpopulated-X control: pymodbus returns 0 (not exception) for any bit in the + // configured DI range that wasn't explicitly seeded — per docs/v2/dl205.md + // "Reading a non-populated X input ... returns zero, not an exception". + new ModbusTagDefinition("DL260_X21_off", + ModbusRegion.DiscreteInputs, Address: DirectLogicAddress.XInputToDiscrete("X21"), + DataType: ModbusDataType.Bool, Writable: false), + ]); + await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-xinput"); + await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var results = await driver.ReadAsync(["DL260_X20", "DL260_X21_off"], TestContext.Current.CancellationToken); + + results[0].StatusCode.ShouldBe(0u); + results[0].Value.ShouldBe(true, "dl205.json seeds cell 1 bit 0 (X20 octal = DI 16) = ON"); + + results[1].StatusCode.ShouldBe(0u, "unpopulated X inputs must read cleanly — DL260 does NOT raise an exception"); + results[1].Value.ShouldBe(false); + } + + private static ModbusDriverOptions BuildOptions(ModbusSimulatorFixture sim, IReadOnlyList tags) + => new() + { + Host = sim.Host, + Port = sim.Port, + UnitId = 1, + Timeout = TimeSpan.FromSeconds(2), + Tags = tags, + Probe = new ModbusProbeOptions { Enabled = false }, + }; +} 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 0f01e8c..f39abd2 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 @@ -36,6 +36,7 @@ [1280, 1282], [1343, 1343], [1407, 1407], + [1, 1], [128, 128], [192, 192], [250, 250], @@ -88,6 +89,9 @@ ], "bits": [ + {"_quirk": "X-input bank marker cell. X0 -> DI 0 conflicts with uint16 V0 at cell 0, so this marker covers X20 octal (= decimal 16 = DI 16 = cell 1 bit 0). X20=ON, X23 octal (DI 19 = cell 1 bit 3)=ON -> cell 1 value = 0b00001001 = 9.", + "addr": 1, "value": 9}, + {"_quirk": "Y-output bank marker cell. pymodbus's simulator maps Modbus FC01/02/05 bit-addresses to cell index = bit_addr / 16; so Modbus coil 2048 lives at cell 128 bit 0. Y0=ON (bit 0), Y1=OFF (bit 1), Y2=ON (bit 2) -> value=0b00000101=5 proves DL260 mapping Y0 -> coil 2048.", "addr": 128, "value": 5},