diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Mitsubishi/MitsubishiProfile.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Mitsubishi/MitsubishiProfile.cs new file mode 100644 index 0000000..a94419a --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Mitsubishi/MitsubishiProfile.cs @@ -0,0 +1,43 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.Mitsubishi; + +/// +/// Tag map for the Mitsubishi MELSEC device class with a representative Modbus Device +/// Assignment block mapping D0..D1023 → HR[0..1023]. Mirrors the behaviors in +/// mitsubishi.json pymodbus profile and docs/v2/mitsubishi.md. +/// +/// +/// MELSEC Modbus sites all have *different* device-assignment parameter blocks; this profile +/// models the conventional default. Per-model differences (FX5U needs firmware ≥ 1.060 for +/// Modbus server; QJ71MT91 lacks FC22/FC23; FX/iQ-F use octal X/Y while Q/L/iQ-R use hex) +/// are handled in (PR 59) and the per-model test files. +/// +public static class MitsubishiProfile +{ + /// + /// Scratch HR the smoke test writes + reads. Address 200 mirrors the + /// dl205/s7_1500/standard scratch range so one smoke test pattern works across every + /// device profile the simulator supports. + /// + public const ushort SmokeHoldingRegister = 200; + + /// Value the smoke test writes then reads back. + public const short SmokeHoldingValue = 7890; + + public static ModbusDriverOptions BuildOptions(string host, int port) => new() + { + Host = host, + Port = port, + UnitId = 1, + Timeout = TimeSpan.FromSeconds(2), + Tags = + [ + new ModbusTagDefinition( + Name: "Smoke_HReg200", + Region: ModbusRegion.HoldingRegisters, + Address: SmokeHoldingRegister, + DataType: ModbusDataType.Int16, + Writable: true), + ], + Probe = new ModbusProbeOptions { Enabled = false }, + }; +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Mitsubishi/MitsubishiSmokeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Mitsubishi/MitsubishiSmokeTests.cs new file mode 100644 index 0000000..7dbd392 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Mitsubishi/MitsubishiSmokeTests.cs @@ -0,0 +1,45 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.Mitsubishi; + +/// +/// End-to-end smoke against the MELSEC mitsubishi.json pymodbus profile (or a real +/// MELSEC QJ71MT91 / iQ-R / FX5U when MODBUS_SIM_ENDPOINT points at one). Drives +/// the full + real stack. +/// Success proves the driver initializes against the MELSEC sim, writes a known value, +/// and reads it back — the baseline every Mitsubishi-specific test (PR 59+) builds on. +/// +[Collection(ModbusSimulatorCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Device", "Mitsubishi")] +public sealed class MitsubishiSmokeTests(ModbusSimulatorFixture sim) +{ + [Fact] + public async Task Mitsubishi_roundtrip_write_then_read_of_holding_register() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "mitsubishi", + StringComparison.OrdinalIgnoreCase)) + { + Assert.Skip("MODBUS_SIM_PROFILE != mitsubishi — skipping."); + } + + var options = MitsubishiProfile.BuildOptions(sim.Host, sim.Port); + await using var driver = new ModbusDriver(options, driverInstanceId: "melsec-smoke"); + await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken); + + var writeResults = await driver.WriteAsync( + [new(FullReference: "Smoke_HReg200", Value: (short)MitsubishiProfile.SmokeHoldingValue)], + TestContext.Current.CancellationToken); + writeResults.Count.ShouldBe(1); + writeResults[0].StatusCode.ShouldBe(0u, "write must succeed against the MELSEC pymodbus 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)MitsubishiProfile.SmokeHoldingValue); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/mitsubishi.json b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/mitsubishi.json new file mode 100644 index 0000000..53fa56c --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/mitsubishi.json @@ -0,0 +1,83 @@ +{ + "_comment": "mitsubishi.json -- Mitsubishi MELSEC Modbus TCP quirk simulator covering QJ71MT91, iQ-R, iQ-F/FX5U, and FX3U-ENET-P502 behaviors documented in docs/v2/mitsubishi.md. MELSEC CPUs store multi-word values in CDAB order (opposite of S7 ABCD, same family as DL260). The Modbus-module 'Modbus Device Assignment Parameter' block is per-site, so this profile models one *representative* assignment mapping D-register D0..D1023 -> HR 0..1023, M-relay M0..M511 -> coil 0..511, X-input X0..X15 -> DI 0..15 (X-addresses are HEX on Q/L/iQ-R, so X10 = decimal 16; on FX/iQ-F they're OCTAL like DL260). pymodbus bit-address semantics are the same as dl205.json and s7_1500.json (FC01/02/05/15 address N maps to cell index N/16).", + + "server_list": { + "srv": { + "comm": "tcp", + "host": "0.0.0.0", + "port": 5020, + "framer": "socket", + "device_id": 1 + } + }, + + "device_list": { + "dev": { + "setup": { + "co size": 4096, + "di size": 4096, + "hr size": 4096, + "ir size": 1024, + "shared blocks": true, + "type exception": false, + "defaults": { + "value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "}, + "action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null} + } + }, + "invalid": [], + "write": [ + [0, 0], + [10, 10], + [100, 101], + [200, 209], + [300, 301], + [500, 500] + ], + + "uint16": [ + {"_quirk": "D0 fingerprint marker. MELSEC D0 is the first data register; Modbus Device Assignment typically maps D0..D1023 -> HR 0..1023. 0x1234 is the fingerprint operators set in GX Works to prove the mapping parameter block is in effect.", + "addr": 0, "value": 4660}, + + {"_quirk": "Scratch HR range 200..209 -- mirrors the dl205/s7_1500/standard scratch range so smoke tests (MitsubishiProfile.SmokeHoldingRegister=200) round-trip identically against any 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": "Float32 1.5f in CDAB word order (MELSEC Q/L/iQ-R/iQ-F default, same as DL260). HR[100]=0x0000=0 low word, HR[101]=0x3FC0=16320 high word. Decode with ByteOrder.WordSwap returns 1.5f; BigEndian decode returns a denormal.", + "addr": 100, "value": 0}, + {"addr": 101, "value": 16320}, + + {"_quirk": "Int32 0x12345678 in CDAB word order. HR[300]=0x5678=22136 low word, HR[301]=0x1234=4660 high word. Contrasts with the S7 profile's ABCD encoding at the same address.", + "addr": 300, "value": 22136}, + {"addr": 301, "value": 4660}, + + {"_quirk": "D10 = decimal 1234 stored as BINARY (NOT BCD like DL205). 0x04D2 = 1234 decimal. Caller reading with Bcd16 data type would decode this as binary 1234's BCD nibbles which are non-BCD and throw InvalidDataException -- proves MELSEC is binary-by-default, opposite of DL205's BCD-by-default quirk.", + "addr": 10, "value": 1234}, + + {"_quirk": "Modbus Device Assignment boundary marker. HR[500] represents the last register in an assigned D-range D500. Beyond this (HR[501..4095]) would be Illegal Data Address on a real QJ71MT91 with this specific parameter block; pymodbus returns default 0 because its shared cell array has space -- real-PLC parity is documented in docs/v2/mitsubishi.md §device-assignment, not enforced here.", + "addr": 500, "value": 500} + ], + + "bits": [ + {"_quirk": "M-relay marker cell at cell 32 = Modbus coil 512 = MELSEC M512 (coils 0..15 collide with the D0 uint16 marker cell, so we place the M marker above that). Cell 32 bit 0 = 1 and bit 2 = 1 (value = 0b101 = 5) = M512=ON, M513=OFF, M514=ON. Matches the Y0/Y2 marker pattern in dl205 and s7_1500 profiles.", + "addr": 32, "value": 5}, + + {"_quirk": "X-input marker cell at cell 33 = Modbus DI 528 (= MELSEC X210 hex on Q/L/iQ-R). Cell 33 bit 0 = 1 and bit 3 = 1 (value = 0x9 = 9). Chosen above cell 1 so it doesn't collide with any uint16 D-register. Proves the hex-parsing X-input helper on Q/L/iQ-R family; FX/iQ-F families use octal X-addresses tested separately.", + "addr": 33, "value": 9} + ], + + "uint32": [], + "float32": [], + "string": [], + "repeat": [] + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj index cbf763c..81c5eae 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj @@ -27,6 +27,7 @@ +