From a44fc7a61051d150ec440e90659d41d52c78cfe0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 23:07:00 -0400 Subject: [PATCH] Phase 3 PR 60 -- Mitsubishi MELSEC quirk integration tests against mitsubishi pymodbus profile. Seven facts in MitsubishiQuirkTests covering the quirks documented in docs/v2/mitsubishi.md that are testable end-to-end via pymodbus: (1) Mitsubishi_D0_fingerprint_reads_0x1234 -- MELSEC operators reserve D0 as a fingerprint word so Modbus clients can verify they're hitting the right Device Assignment block; test reads HR[0]=0x1234 via DRegisterToHolding('D0') helper. (2) Mitsubishi_Float32_CDAB_decodes_1_5f_from_D100 -- reads HR[100..101] with WordSwap AND BigEndian; asserts WordSwap==1.5f AND BigEndian!=1.5f, proving (a) MELSEC uses CDAB default same as DL260, (b) opposite of S7 ABCD, (c) driver flag is not a no-op. (3) Mitsubishi_D10_is_binary_not_BCD -- reads HR[10]=0x04D2 as Int16 and asserts value 1234 (binary decode), contrasting with DL205's BCD-by-default convention. (4) Mitsubishi_D10_as_BCD_throws_because_nibble_is_non_decimal -- reads same HR[10] as Bcd16 and asserts StatusCode != 0 because nibble 0xD fails BCD validation; proves the BCD decoder fails loud when the tag config is wrong rather than silently returning garbage. (5) Mitsubishi_QLiQR_X210_hex_maps_to_DI_528_reads_ON -- reads FC02 at the MelsecAddress.XInputToDiscrete('X210', Q_L_iQR)-resolved address (=528 decimal) and asserts ON; proves the hex-parsing path end-to-end. (6) Mitsubishi_family_trap_X20_differs_on_Q_vs_FX -- unit-level proof in the integration file so the headline family trap is visible to anyone filtering by Device=Mitsubishi. (7) Mitsubishi_M512_maps_to_coil_512_reads_ON -- reads FC01 at MRelayToCoil('M512')=512 (decimal) and asserts ON; proves the decimal M-relay path. Test fixture pattern: single MitsubishiQuirkTests class with a shared ShouldRun + NewDriverAsync helper rather than per-quirk classes (contrast with DL205's per-quirk splits). MELSEC per-model differentiation is handled by MelsecFamily enum on the helper rather than per-PR -- so one quirk file + one family enum covers Q/L/iQ-R/FX/iQ-F, and a new PLC family just adds an enum case instead of a new test class. 8/8 Mitsubishi integration tests pass (1 smoke + 7 quirk). 176/176 Modbus.Tests unit suite still green. S7 + DL205 integration tests can be run against their respective profiles by swapping MODBUS_SIM_PROFILE and restarting the pymodbus sim -- each family gates on its profile env var so no cross-family test pollution. --- .../Mitsubishi/MitsubishiQuirkTests.cs | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Mitsubishi/MitsubishiQuirkTests.cs diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Mitsubishi/MitsubishiQuirkTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Mitsubishi/MitsubishiQuirkTests.cs new file mode 100644 index 0000000..b342207 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Mitsubishi/MitsubishiQuirkTests.cs @@ -0,0 +1,179 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.Mitsubishi; + +/// +/// Verifies the MELSEC-family Modbus quirks against the mitsubishi.json pymodbus +/// profile: CDAB word order default, binary-not-BCD D-register encoding, hex X-input +/// parsing (Q/L/iQ-R), D0 fingerprint, M-relay coil mapping with bank base. +/// +/// +/// Groups all quirks in one test class instead of per-behavior classes (unlike the DL205 +/// set) because MELSEC's per-model differentiation is handled by the +/// enum on the helper + MODBUS_SIM_PROFILE env var on +/// the fixture, rather than per-PR test classes. +/// +[Collection(ModbusSimulatorCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Device", "Mitsubishi")] +public sealed class MitsubishiQuirkTests(ModbusSimulatorFixture sim) +{ + [Fact] + public async Task Mitsubishi_D0_fingerprint_reads_0x1234() + { + if (!ShouldRun()) return; + await using var driver = await NewDriverAsync( + new ModbusTagDefinition("D0_Fingerprint", + ModbusRegion.HoldingRegisters, + Address: MelsecAddress.DRegisterToHolding("D0"), + DataType: ModbusDataType.UInt16, Writable: false)); + + var r = await driver.ReadAsync(["D0_Fingerprint"], TestContext.Current.CancellationToken); + r[0].StatusCode.ShouldBe(0u); + r[0].Value.ShouldBe((ushort)0x1234); + } + + [Fact] + public async Task Mitsubishi_Float32_CDAB_decodes_1_5f_from_D100() + { + if (!ShouldRun()) return; + // MELSEC Q/L/iQ-R/iQ-F all store 32-bit values with CDAB word order (low word at + // lower D-register address). HR[100..101] = [0, 0x3FC0] decodes as 1.5f under + // WordSwap but as a denormal under BigEndian. + var addr = MelsecAddress.DRegisterToHolding("D100"); + await using var driver = await NewDriverAsync( + new ModbusTagDefinition("D100_Float_CDAB", + ModbusRegion.HoldingRegisters, Address: addr, + DataType: ModbusDataType.Float32, Writable: false, + ByteOrder: ModbusByteOrder.WordSwap), + new ModbusTagDefinition("D100_Float_ABCD_control", + ModbusRegion.HoldingRegisters, Address: addr, + DataType: ModbusDataType.Float32, Writable: false, + ByteOrder: ModbusByteOrder.BigEndian)); + + var r = await driver.ReadAsync( + ["D100_Float_CDAB", "D100_Float_ABCD_control"], + TestContext.Current.CancellationToken); + r[0].Value.ShouldBe(1.5f, "MELSEC stores Float32 CDAB; WordSwap decode returns 1.5f"); + r[1].Value.ShouldNotBe(1.5f, "same wire with BigEndian must decode to a different value"); + } + + [Fact] + public async Task Mitsubishi_D10_is_binary_not_BCD() + { + if (!ShouldRun()) return; + // Counter-to-DL205: MELSEC D-registers are binary by default. D10 = 1234 decimal = + // 0x04D2. Reading as Int16 returns 1234; reading as Bcd16 would throw (nibble 0xD is + // non-BCD) — the integration test proves the Int16 decode wins. + await using var driver = await NewDriverAsync( + new ModbusTagDefinition("D10_Binary", + ModbusRegion.HoldingRegisters, + Address: MelsecAddress.DRegisterToHolding("D10"), + DataType: ModbusDataType.Int16, Writable: false)); + + var r = await driver.ReadAsync(["D10_Binary"], TestContext.Current.CancellationToken); + r[0].StatusCode.ShouldBe(0u); + r[0].Value.ShouldBe((short)1234, "MELSEC stores numeric D-register values in binary; 0x04D2 = 1234"); + } + + [Fact] + public async Task Mitsubishi_D10_as_BCD_throws_because_nibble_is_non_decimal() + { + if (!ShouldRun()) return; + // If a site configured D10 with Bcd16 data type but the ladder writes binary, the + // BCD decoder MUST reject the garbage rather than silently returning wrong decimal. + // 0x04D2 contains nibble 0xD which fails BCD validation. + await using var driver = await NewDriverAsync( + new ModbusTagDefinition("D10_WrongBcd", + ModbusRegion.HoldingRegisters, + Address: MelsecAddress.DRegisterToHolding("D10"), + DataType: ModbusDataType.Bcd16, Writable: false)); + + var r = await driver.ReadAsync(["D10_WrongBcd"], TestContext.Current.CancellationToken); + // ReadAsync catches the InvalidDataException from DecodeBcd and surfaces it as + // BadCommunicationError (PR 52 mapping). Non-zero status = caller sees a real + // problem and can check their tag config instead of getting silently-wrong numbers. + r[0].StatusCode.ShouldNotBe(0u, "BCD decode of binary 0x04D2 must fail loudly because nibble D is non-BCD"); + } + + [Fact] + public async Task Mitsubishi_QLiQR_X210_hex_maps_to_DI_528_reads_ON() + { + if (!ShouldRun()) return; + // MELSEC-Q / L / iQ-R: X addresses are hex. X210 = 0x210 = 528 decimal. + // mitsubishi.json seeds cell 33 (DI 528..543) with value 9 = bit 0 + bit 3 set. + // X210 → DI 528 → cell 33 bit 0 = 1 (ON). + var addr = MelsecAddress.XInputToDiscrete("X210", MelsecFamily.Q_L_iQR); + addr.ShouldBe((ushort)528); + + await using var driver = await NewDriverAsync( + new ModbusTagDefinition("X210_hex", + ModbusRegion.DiscreteInputs, Address: addr, + DataType: ModbusDataType.Bool, Writable: false)); + + var r = await driver.ReadAsync(["X210_hex"], TestContext.Current.CancellationToken); + r[0].StatusCode.ShouldBe(0u); + r[0].Value.ShouldBe(true); + } + + [Fact] + public void Mitsubishi_family_trap_X20_differs_on_Q_vs_FX() + { + // Not a live-sim test — a unit-level proof that the MELSEC family selector gates the + // address correctly. Included in the integration suite so anyone running the MELSEC + // tests sees the trap called out explicitly. + MelsecAddress.XInputToDiscrete("X20", MelsecFamily.Q_L_iQR).ShouldBe((ushort)32); + MelsecAddress.XInputToDiscrete("X20", MelsecFamily.F_iQF).ShouldBe((ushort)16); + } + + [Fact] + public async Task Mitsubishi_M512_maps_to_coil_512_reads_ON() + { + if (!ShouldRun()) return; + // mitsubishi.json seeds cell 32 (coil 512..527) with value 5 = bit 0 + bit 2 set. + // M512 → coil 512 → cell 32 bit 0 = 1 (ON). + var addr = MelsecAddress.MRelayToCoil("M512"); + addr.ShouldBe((ushort)512); + + await using var driver = await NewDriverAsync( + new ModbusTagDefinition("M512", + ModbusRegion.Coils, Address: addr, + DataType: ModbusDataType.Bool, Writable: false)); + + var r = await driver.ReadAsync(["M512"], TestContext.Current.CancellationToken); + r[0].StatusCode.ShouldBe(0u); + r[0].Value.ShouldBe(true); + } + + // --- helpers --- + + private bool ShouldRun() + { + if (sim.SkipReason is not null) { Assert.Skip(sim.SkipReason); return false; } + if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "mitsubishi", + StringComparison.OrdinalIgnoreCase)) + { + Assert.Skip("MODBUS_SIM_PROFILE != mitsubishi — skipping."); + return false; + } + return true; + } + + private async Task NewDriverAsync(params ModbusTagDefinition[] tags) + { + var drv = new ModbusDriver( + new ModbusDriverOptions + { + Host = sim.Host, + Port = sim.Port, + UnitId = 1, + Timeout = TimeSpan.FromSeconds(2), + Tags = tags, + Probe = new ModbusProbeOptions { Enabled = false }, + }, + driverInstanceId: "melsec-quirk"); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + return drv; + } +} -- 2.49.1