diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/DirectLogicAddress.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/DirectLogicAddress.cs index 000601e..8cc60ce 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/DirectLogicAddress.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/DirectLogicAddress.cs @@ -71,4 +71,95 @@ public static class DirectLogicAddress $"System V-memory offset {offsetWithinSystemBank} maps past 0xFFFF"); return (ushort)pdu; } + + // Bit-memory bases per DL260 user manual §I/O-configuration. + // Numbers after X / Y / C / SP are OCTAL in DirectLOGIC notation. The Modbus base is + // added to the octal-decoded offset; e.g. Y017 = Modbus coil 2048 + octal(17) = 2048 + 15 = 2063. + + /// + /// DL260 Y-output coil base. Y0 octal → Modbus coil address 2048 (0-based). + /// + public const ushort YOutputBaseCoil = 2048; + + /// + /// DL260 C-relay coil base. C0 octal → Modbus coil address 3072 (0-based). + /// + public const ushort CRelayBaseCoil = 3072; + + /// + /// DL260 X-input discrete-input base. X0 octal → Modbus discrete input 0. + /// + public const ushort XInputBaseDiscrete = 0; + + /// + /// DL260 SP special-relay discrete-input base. SP0 octal → Modbus discrete input 1024. + /// Read-only; writing SP relays is rejected with Illegal Data Address. + /// + public const ushort SpecialBaseDiscrete = 1024; + + /// + /// Translate a DirectLOGIC Y-output address (e.g. "Y0", "Y17") to its + /// 0-based Modbus coil address on DL260. The trailing number is OCTAL, matching the + /// ladder-logic editor's notation. + /// + public static ushort YOutputToCoil(string yAddress) => + AddOctalOffset(YOutputBaseCoil, StripPrefix(yAddress, 'Y')); + + /// + /// Translate a DirectLOGIC C-relay address (e.g. "C0", "C1777") to its + /// 0-based Modbus coil address. + /// + public static ushort CRelayToCoil(string cAddress) => + AddOctalOffset(CRelayBaseCoil, StripPrefix(cAddress, 'C')); + + /// + /// Translate a DirectLOGIC X-input address (e.g. "X0", "X17") to its + /// 0-based Modbus discrete-input address. Reading an unpopulated X returns 0, not an + /// exception — the CPU sizes the table to configured I/O, not installed modules. + /// + public static ushort XInputToDiscrete(string xAddress) => + AddOctalOffset(XInputBaseDiscrete, StripPrefix(xAddress, 'X')); + + /// + /// Translate a DirectLOGIC SP-special-relay address (e.g. "SP0") to its 0-based + /// Modbus discrete-input address. Accepts "SP" prefix case-insensitively. + /// + public static ushort SpecialToDiscrete(string spAddress) + { + if (string.IsNullOrWhiteSpace(spAddress)) + throw new ArgumentException("SP address must not be empty", nameof(spAddress)); + var s = spAddress.Trim(); + if (s.Length >= 2 && (s[0] == 'S' || s[0] == 's') && (s[1] == 'P' || s[1] == 'p')) + s = s.Substring(2); + return AddOctalOffset(SpecialBaseDiscrete, s); + } + + private static string StripPrefix(string address, char expectedPrefix) + { + if (string.IsNullOrWhiteSpace(address)) + throw new ArgumentException("Address must not be empty", nameof(address)); + var s = address.Trim(); + if (s.Length > 0 && char.ToUpperInvariant(s[0]) == char.ToUpperInvariant(expectedPrefix)) + s = s.Substring(1); + return s; + } + + private static ushort AddOctalOffset(ushort baseAddr, string octalDigits) + { + if (octalDigits.Length == 0) + throw new ArgumentException("Address has no digits", nameof(octalDigits)); + uint offset = 0; + foreach (var ch in octalDigits) + { + if (ch < '0' || ch > '7') + throw new ArgumentException( + $"Address contains non-octal digit '{ch}' — DirectLOGIC I/O addresses are octal (0-7)", + nameof(octalDigits)); + offset = offset * 8 + (uint)(ch - '0'); + } + var result = baseAddr + offset; + if (result > ushort.MaxValue) + throw new OverflowException($"Address {baseAddr}+{offset} exceeds 0xFFFF"); + return (ushort)result; + } } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205CoilMappingTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205CoilMappingTests.cs new file mode 100644 index 0000000..07c6f4f --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205CoilMappingTests.cs @@ -0,0 +1,109 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205; + +/// +/// Verifies DL260 I/O-memory coil mappings against the dl205.json pymodbus profile. +/// DirectLOGIC Y-outputs and C-relays are exposed to Modbus as FC01/FC05 coils, but at +/// non-zero base addresses that confuse operators used to "Y0 is the first coil". The sim +/// seeds Y0 → coil 2048 = ON and C0 → coil 3072 = ON as fixed markers. +/// +[Collection(ModbusSimulatorCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Device", "DL205")] +public sealed class DL205CoilMappingTests(ModbusSimulatorFixture sim) +{ + [Fact] + public async Task DL260_Y0_maps_to_coil_2048() + { + 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."); + } + + var coil = DirectLogicAddress.YOutputToCoil("Y0"); + coil.ShouldBe((ushort)2048); + + var options = BuildOptions(sim, [ + new ModbusTagDefinition("DL260_Y0", + ModbusRegion.Coils, Address: coil, + DataType: ModbusDataType.Bool, Writable: false), + ]); + await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-y0"); + await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var results = await driver.ReadAsync(["DL260_Y0"], TestContext.Current.CancellationToken); + results[0].StatusCode.ShouldBe(0u); + results[0].Value.ShouldBe(true, "dl205.json seeds coil 2048 (Y0) = ON"); + } + + [Fact] + public async Task DL260_C0_maps_to_coil_3072() + { + 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."); + } + + var coil = DirectLogicAddress.CRelayToCoil("C0"); + coil.ShouldBe((ushort)3072); + + var options = BuildOptions(sim, [ + new ModbusTagDefinition("DL260_C0", + ModbusRegion.Coils, Address: coil, + DataType: ModbusDataType.Bool, Writable: false), + ]); + await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-c0"); + await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var results = await driver.ReadAsync(["DL260_C0"], TestContext.Current.CancellationToken); + results[0].StatusCode.ShouldBe(0u); + results[0].Value.ShouldBe(true, "dl205.json seeds coil 3072 (C0) = ON"); + } + + [Fact] + public async Task DL260_scratch_Crelay_supports_write_then_read() + { + 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."); + } + + // Scratch C-relay at coil 4000 (per dl205.json _quirk note) is writable. Write=true then + // read back to confirm FC05 round-trip works against the DL-mapped coil bank. + var options = BuildOptions(sim, [ + new ModbusTagDefinition("DL260_C_Scratch", + ModbusRegion.Coils, Address: 4000, + DataType: ModbusDataType.Bool, Writable: true), + ]); + await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-cscratch"); + await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var writeResults = await driver.WriteAsync( + [new(FullReference: "DL260_C_Scratch", Value: true)], + TestContext.Current.CancellationToken); + writeResults[0].StatusCode.ShouldBe(0u); + + var readResults = await driver.ReadAsync(["DL260_C_Scratch"], TestContext.Current.CancellationToken); + readResults[0].StatusCode.ShouldBe(0u); + readResults[0].Value.ShouldBe(true); + } + + 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 0686a49..0f01e8c 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,9 +36,9 @@ [1280, 1282], [1343, 1343], [1407, 1407], - [2048, 2050], - [3072, 3074], - [4000, 4007], + [128, 128], + [192, 192], + [250, 250], [8448, 8448] ], @@ -88,25 +88,14 @@ ], "bits": [ - {"_quirk": "Y0 marker. DL260 maps Y0 to coil 2048 (0-based). Coil 2048 = ON proves the mapping.", - "addr": 2048, "value": 1}, - {"addr": 2049, "value": 0}, - {"addr": 2050, "value": 1}, + {"_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}, - {"_quirk": "C0 marker. DL260 maps C0 to coil 3072 (0-based). Coil 3072 = ON proves the mapping.", - "addr": 3072, "value": 1}, - {"addr": 3073, "value": 0}, - {"addr": 3074, "value": 1}, + {"_quirk": "C-relay bank marker cell. Modbus coil 3072 -> cell 192 bit 0. C0=ON (bit 0), C1=OFF (bit 1), C2=ON (bit 2) -> value=5 proves DL260 mapping C0 -> coil 3072.", + "addr": 192, "value": 5}, - {"_quirk": "Scratch C-relays for write-roundtrip tests against the writable C range.", - "addr": 4000, "value": 0}, - {"addr": 4001, "value": 0}, - {"addr": 4002, "value": 0}, - {"addr": 4003, "value": 0}, - {"addr": 4004, "value": 0}, - {"addr": 4005, "value": 0}, - {"addr": 4006, "value": 0}, - {"addr": 4007, "value": 0} + {"_quirk": "Scratch cell for coil 4000..4015 write round-trip tests. Cell 250 holds Modbus coils 4000-4015; all bits start at 0 and tests set specific bits via FC05.", + "addr": 250, "value": 0} ], "uint32": [], diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/DirectLogicAddressTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/DirectLogicAddressTests.cs index 75e64ed..9fe36e7 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/DirectLogicAddressTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/DirectLogicAddressTests.cs @@ -74,4 +74,66 @@ public sealed class DirectLogicAddressTests Should.NotThrow(() => DirectLogicAddress.SystemVMemoryToPdu(0xDEFF)); Should.Throw(() => DirectLogicAddress.SystemVMemoryToPdu(0xDF00)); } + + // --- Bit memory: Y-output, C-relay, X-input, SP-special --- + + [Theory] + [InlineData("Y0", (ushort)2048)] + [InlineData("Y1", (ushort)2049)] + [InlineData("Y7", (ushort)2055)] + [InlineData("Y10", (ushort)2056)] // octal 10 = decimal 8 + [InlineData("Y17", (ushort)2063)] // octal 17 = decimal 15 + [InlineData("Y777", (ushort)2559)] // top of DL260 Y range per doc table + public void YOutputToCoil_adds_octal_offset_to_2048(string y, ushort expected) + => DirectLogicAddress.YOutputToCoil(y).ShouldBe(expected); + + [Theory] + [InlineData("C0", (ushort)3072)] + [InlineData("C1", (ushort)3073)] + [InlineData("C10", (ushort)3080)] + [InlineData("C1777", (ushort)4095)] // top of DL260 C range + public void CRelayToCoil_adds_octal_offset_to_3072(string c, ushort expected) + => DirectLogicAddress.CRelayToCoil(c).ShouldBe(expected); + + [Theory] + [InlineData("X0", (ushort)0)] + [InlineData("X17", (ushort)15)] + [InlineData("X777", (ushort)511)] // top of DL260 X range + public void XInputToDiscrete_adds_octal_offset_to_0(string x, ushort expected) + => DirectLogicAddress.XInputToDiscrete(x).ShouldBe(expected); + + [Theory] + [InlineData("SP0", (ushort)1024)] + [InlineData("SP7", (ushort)1031)] + [InlineData("sp0", (ushort)1024)] // lowercase prefix + [InlineData("SP777", (ushort)1535)] + public void SpecialToDiscrete_adds_octal_offset_to_1024(string sp, ushort expected) + => DirectLogicAddress.SpecialToDiscrete(sp).ShouldBe(expected); + + [Theory] + [InlineData("Y8")] + [InlineData("C9")] + [InlineData("X18")] + public void Bit_address_rejects_non_octal_digits(string bad) + => Should.Throw(() => + { + if (bad[0] == 'Y') DirectLogicAddress.YOutputToCoil(bad); + else if (bad[0] == 'C') DirectLogicAddress.CRelayToCoil(bad); + else DirectLogicAddress.XInputToDiscrete(bad); + }); + + [Theory] + [InlineData("Y")] + [InlineData("C")] + [InlineData("")] + public void Bit_address_rejects_empty(string bad) + => Should.Throw(() => DirectLogicAddress.YOutputToCoil(bad)); + + [Fact] + public void YOutputToCoil_accepts_lowercase_prefix() + => DirectLogicAddress.YOutputToCoil("y0").ShouldBe((ushort)2048); + + [Fact] + public void CRelayToCoil_accepts_bare_octal_without_C_prefix() + => DirectLogicAddress.CRelayToCoil("0").ShouldBe((ushort)3072); }