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; } }