diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/DirectLogicAddress.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/DirectLogicAddress.cs new file mode 100644 index 0000000..000601e --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/DirectLogicAddress.cs @@ -0,0 +1,74 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus; + +/// +/// AutomationDirect DirectLOGIC address-translation helpers. DL205 / DL260 / DL350 CPUs +/// address V-memory in OCTAL while the Modbus wire uses DECIMAL PDU addresses — operators +/// see "V2000" in the PLC ladder-logic editor but the Modbus client must write PDU 0x0400. +/// The formulas differ between user V-memory (simple octal-to-decimal) and system V-memory +/// (fixed bank mappings), so the two cases are separate methods rather than one overloaded +/// "ToPdu" call. +/// +/// +/// See docs/v2/dl205.md §V-memory for the full CPU-family matrix + rationale. +/// References: D2-USER-M appendix (DL205/D2-260), H2-ECOM-M §6.5 (absolute vs relative +/// addressing), AutomationDirect forum guidance on V40400 system-base. +/// +public static class DirectLogicAddress +{ + /// + /// Convert a DirectLOGIC user V-memory address (octal) to a 0-based Modbus PDU address. + /// Accepts bare octal ("2000") or V-prefixed ("V2000"). Range + /// depends on CPU model — DL205 D2-260 user memory is V1400-V7377 + V10000-V17777 + /// octal, DL260 extends to V77777 octal. + /// + /// Input is null / empty / contains non-octal digits (8,9). + /// Parsed value exceeds ushort.MaxValue (0xFFFF). + public static ushort UserVMemoryToPdu(string vAddress) + { + if (string.IsNullOrWhiteSpace(vAddress)) + throw new ArgumentException("V-memory address must not be empty", nameof(vAddress)); + var s = vAddress.Trim(); + if (s[0] == 'V' || s[0] == 'v') s = s.Substring(1); + if (s.Length == 0) + throw new ArgumentException($"V-memory address '{vAddress}' has no digits", nameof(vAddress)); + + // Octal conversion. Reject 8/9 digits up-front — int.Parse in the obvious base would + // accept them silently because .NET has no built-in base-8 parser. + uint result = 0; + foreach (var ch in s) + { + if (ch < '0' || ch > '7') + throw new ArgumentException( + $"V-memory address '{vAddress}' contains non-octal digit '{ch}' — DirectLOGIC V-addresses are octal (0-7)", + nameof(vAddress)); + result = result * 8 + (uint)(ch - '0'); + if (result > ushort.MaxValue) + throw new OverflowException( + $"V-memory address '{vAddress}' exceeds the 16-bit Modbus PDU address range"); + } + return (ushort)result; + } + + /// + /// DirectLOGIC system V-memory starts at octal V40400 on DL260 / H2-ECOM100 in factory + /// "absolute" addressing mode. Unlike user V-memory, the mapping is NOT a simple + /// octal-to-decimal conversion — the CPU relocates the system bank to Modbus PDU 0x2100 + /// (decimal 8448). This helper returns the CPU-family base plus a user-supplied offset + /// within the system bank. + /// + public const ushort SystemVMemoryBasePdu = 0x2100; + + /// + /// 0-based register offset within the system bank. Pass 0 for V40400 itself; pass 1 for + /// V40401 (octal), and so on. NOT an octal-decoded value — the system bank lives at + /// consecutive PDU addresses, so the offset is plain decimal. + /// + public static ushort SystemVMemoryToPdu(ushort offsetWithinSystemBank) + { + var pdu = SystemVMemoryBasePdu + offsetWithinSystemBank; + if (pdu > ushort.MaxValue) + throw new OverflowException( + $"System V-memory offset {offsetWithinSystemBank} maps past 0xFFFF"); + return (ushort)pdu; + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205VMemoryQuirkTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205VMemoryQuirkTests.cs new file mode 100644 index 0000000..b8f91d4 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205VMemoryQuirkTests.cs @@ -0,0 +1,91 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205; + +/// +/// Verifies the DL205/DL260 V-memory octal addressing quirk end-to-end: use +/// to translate V2000 octal into +/// the Modbus PDU address actually dispatched, then read the marker the dl205.json profile +/// placed at that address. HR[0x0400] = 0x2000 proves the translation was performed +/// correctly — a naïve caller treating "V2000" as decimal 2000 would read HR[2000] (which +/// the profile leaves at 0) and miss the marker entirely. +/// +[Collection(ModbusSimulatorCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Device", "DL205")] +public sealed class DL205VMemoryQuirkTests(ModbusSimulatorFixture sim) +{ + [Fact] + public async Task DL205_V2000_user_memory_resolves_to_PDU_0x0400_marker() + { + 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 (standard profile does not seed V-memory markers)."); + } + + var pdu = DirectLogicAddress.UserVMemoryToPdu("V2000"); + pdu.ShouldBe((ushort)0x0400); + + var options = new ModbusDriverOptions + { + Host = sim.Host, + Port = sim.Port, + UnitId = 1, + Timeout = TimeSpan.FromSeconds(2), + Tags = + [ + new ModbusTagDefinition("DL205_V2000", + ModbusRegion.HoldingRegisters, Address: pdu, + DataType: ModbusDataType.UInt16, Writable: false), + ], + Probe = new ModbusProbeOptions { Enabled = false }, + }; + await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-vmem"); + await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var results = await driver.ReadAsync(["DL205_V2000"], TestContext.Current.CancellationToken); + results[0].StatusCode.ShouldBe(0u); + results[0].Value.ShouldBe((ushort)0x2000, "dl205.json seeds HR[0x0400] with marker 0x2000 (= V2000 value)"); + } + + [Fact] + public async Task DL205_V40400_system_memory_resolves_to_PDU_0x2100_marker() + { + 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."); + } + + // V40400 is system memory on DL260 / H2-ECOM100 absolute mode; it does NOT follow the + // simple octal-to-decimal formula (40400 octal = 16640 decimal, which would read HR[0x4100]). + // The CPU places the system bank at PDU 0x2100 instead. Proving the helper routes there. + var pdu = DirectLogicAddress.SystemVMemoryToPdu(0); + pdu.ShouldBe((ushort)0x2100); + + var options = new ModbusDriverOptions + { + Host = sim.Host, + Port = sim.Port, + UnitId = 1, + Timeout = TimeSpan.FromSeconds(2), + Tags = + [ + new ModbusTagDefinition("DL205_V40400", + ModbusRegion.HoldingRegisters, Address: pdu, + DataType: ModbusDataType.UInt16, Writable: false), + ], + Probe = new ModbusProbeOptions { Enabled = false }, + }; + await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-sysv"); + await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); + + var results = await driver.ReadAsync(["DL205_V40400"], TestContext.Current.CancellationToken); + results[0].StatusCode.ShouldBe(0u); + results[0].Value.ShouldBe((ushort)0x4040, "dl205.json seeds HR[0x2100] with marker 0x4040 (= V40400 value)"); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/DirectLogicAddressTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/DirectLogicAddressTests.cs new file mode 100644 index 0000000..75e64ed --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/DirectLogicAddressTests.cs @@ -0,0 +1,77 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; + +[Trait("Category", "Unit")] +public sealed class DirectLogicAddressTests +{ + [Theory] + [InlineData("V0", (ushort)0x0000)] + [InlineData("V1", (ushort)0x0001)] + [InlineData("V7", (ushort)0x0007)] + [InlineData("V10", (ushort)0x0008)] + [InlineData("V2000", (ushort)0x0400)] // canonical DL205/DL260 user-memory start + [InlineData("V7777", (ushort)0x0FFF)] + [InlineData("V10000", (ushort)0x1000)] + [InlineData("V17777", (ushort)0x1FFF)] + public void UserVMemoryToPdu_converts_octal_V_prefix(string v, ushort expected) + => DirectLogicAddress.UserVMemoryToPdu(v).ShouldBe(expected); + + [Theory] + [InlineData("0", (ushort)0)] + [InlineData("2000", (ushort)0x0400)] + [InlineData("v2000", (ushort)0x0400)] // lowercase v + [InlineData(" V2000 ", (ushort)0x0400)] // surrounding whitespace + public void UserVMemoryToPdu_accepts_bare_or_prefixed_or_padded(string v, ushort expected) + => DirectLogicAddress.UserVMemoryToPdu(v).ShouldBe(expected); + + [Theory] + [InlineData("V8")] // 8 is not a valid octal digit + [InlineData("V19")] + [InlineData("V2009")] + public void UserVMemoryToPdu_rejects_non_octal_digits(string v) + { + Should.Throw(() => DirectLogicAddress.UserVMemoryToPdu(v)) + .Message.ShouldContain("octal"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("V")] + public void UserVMemoryToPdu_rejects_empty_input(string? v) + => Should.Throw(() => DirectLogicAddress.UserVMemoryToPdu(v!)); + + [Fact] + public void UserVMemoryToPdu_overflow_rejected() + { + // 200000 octal = 0x10000 — one past ushort range. + Should.Throw(() => DirectLogicAddress.UserVMemoryToPdu("V200000")); + } + + [Fact] + public void SystemVMemoryBasePdu_is_0x2100_for_V40400() + { + // V40400 on DL260 / H2-ECOM100 absolute mode → PDU 0x2100 (decimal 8448), NOT 0x4100 + // which a naive octal-to-decimal of 40400 octal would give (= 16640). + DirectLogicAddress.SystemVMemoryBasePdu.ShouldBe((ushort)0x2100); + DirectLogicAddress.SystemVMemoryToPdu(0).ShouldBe((ushort)0x2100); + } + + [Fact] + public void SystemVMemoryToPdu_offsets_within_bank() + { + DirectLogicAddress.SystemVMemoryToPdu(1).ShouldBe((ushort)0x2101); + DirectLogicAddress.SystemVMemoryToPdu(0x100).ShouldBe((ushort)0x2200); + } + + [Fact] + public void SystemVMemoryToPdu_rejects_overflow() + { + // ushort wrap: 0xFFFF - 0x2100 = 0xDEFF; anything above should throw. + Should.NotThrow(() => DirectLogicAddress.SystemVMemoryToPdu(0xDEFF)); + Should.Throw(() => DirectLogicAddress.SystemVMemoryToPdu(0xDF00)); + } +}