From 2b5222f5dbbdeadb684191565cc7e8448f1b6615 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 21:49:58 -0400 Subject: [PATCH] Phase 3 PR 47 -- DL205 V-memory octal-address helper. Adds DirectLogicAddress static class with two entry points: UserVMemoryToPdu(string) parses a DirectLOGIC V-address (V-prefixed or bare, whitespace tolerated) as OCTAL and returns the 0-based Modbus PDU address. V2000 octal = decimal 1024 = PDU 0x0400, which is the canonical start of the user V-memory bank on DL205/DL260. SystemVMemoryBasePdu + SystemVMemoryToPdu(ushort offset) handle the system bank (V40400 and up) which does NOT follow the simple octal-to-decimal formula -- the CPU relocates the system bank to PDU 0x2100 in H2-ECOM100 absolute mode. A naive caller converting 40400 octal would land at PDU 0x4100 (decimal 16640) and miss the system registers entirely; the helper routes the correct 0x2100 base. Why this matters: DirectLOGIC operators think in OCTAL (the ladder-logic editor, the Productivity/Do-more UI, every AutomationDirect manual addresses V-memory octally) while the Modbus wire is DECIMAL. Integrators routinely copy V-addresses from the PLC documentation into client configs and read garbage because they treated V2000 as decimal 2000 (HR[2000] = 0 in the dl205 sim, zero in most PLCs). The helper makes the translation explicit per the D2-USER-M appendix + H2-ECOM-M \u00A76.5 references cited in docs/v2/dl205.md. Unit tests: UserVMemoryToPdu_converts_octal_V_prefix (V0, V1, V7, V10, V2000, V7777, V10000, V17777 -- the exact sweep documented in dl205.md), UserVMemoryToPdu_accepts_bare_or_prefixed_or_padded (case + whitespace tolerance), UserVMemoryToPdu_rejects_non_octal_digits (V8/V19/V2009 must throw ArgumentException with 'octal' in the message -- .NET has no base-8 int.Parse so we hand-walk digits to catch 8/9 instead of silently accepting them), UserVMemoryToPdu_rejects_empty_input, UserVMemoryToPdu_overflow_rejected (200000 octal = 0x10000 overflows ushort), SystemVMemoryBasePdu_is_0x2100_for_V40400, SystemVMemoryToPdu_offsets_within_bank, SystemVMemoryToPdu_rejects_overflow. 23/23 Modbus.Tests pass. Integration tests against dl205.json pymodbus profile: DL205_V2000_user_memory_resolves_to_PDU_0x0400_marker (reads HR[0x0400]=0x2000), DL205_V40400_system_memory_resolves_to_PDU_0x2100_marker (reads HR[0x2100]=0x4040). 5/5 DL205 integration tests pass. Caller opts into the helper per tag by calling DirectLogicAddress.UserVMemoryToPdu("V2000") as the ModbusTagDefinition Address -- no driver-wide "DL205 mode" flag needed, because users mix DL and non-DL tags in a single driver instance all the time. --- .../DirectLogicAddress.cs | 74 +++++++++++++++ .../DL205/DL205VMemoryQuirkTests.cs | 91 +++++++++++++++++++ .../DirectLogicAddressTests.cs | 77 ++++++++++++++++ 3 files changed, 242 insertions(+) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/DirectLogicAddress.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205VMemoryQuirkTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/DirectLogicAddressTests.cs 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)); + } +} -- 2.49.1