Phase 3 PR 50 -- DL260 bit-memory address helpers (Y/C/X/SP) + live coil integration tests. Adds four new static helpers to DirectLogicAddress covering every discrete-memory bank on the DL260: YOutputToCoil (Y0=coil 2048), CRelayToCoil (C0=coil 3072), XInputToDiscrete (X0=DI 0), SpecialToDiscrete (SP0=DI 1024). Each helper takes the DirectLOGIC ladder-logic address (e.g. 'Y0', 'Y17', 'C1777') and adds the octal-decoded offset to the bank's Modbus base per the DL260 user manual's I/O-configuration chapter table. Uses the same 'octal-walk + reject 8/9' pattern as UserVMemoryToPdu so misaligned addresses fail loudly with a clear ArgumentException rather than silently hitting the wrong coil. Fixes a pymodbus-config bug surfaced during integration-test validation: dl205.json had bits entries at cell indices 2048 / 3072 / 4000, but pymodbus's ModbusSimulatorContext.validate divides bit addresses by 16 before indexing into the shared cell array -- so Modbus coil 2048 reads cell 128, not cell 2048. The sim was returning Illegal Data Address (exception 02) for every bit read in the Y/C/scratch range. Moved bits entries to cells 128 (Y bank marker = 0b101 for Y0=ON, Y1=OFF, Y2=ON), 192 (C bank marker = 0b101 for C0/C1/C2), 250 (scratch cell covering coils 4000..4015). write list updated to the correct cell addresses. Unit tests: YOutputToCoil theory sweep (Y0->2048, Y1->2049, Y7->2055, Y10->2056 octal-to-decimal, Y17->2063, Y777->2559 top of DL260 Y range), CRelayToCoil theory (C0->3072 through C1777->4095), XInputToDiscrete theory, SpecialToDiscrete theory (with case-insensitive 'SP' prefix). Bit_address_rejects_non_octal_digits (Y8/C9/X18), Bit_address_rejects_empty, accepts_lowercase_prefix, accepts_bare_octal_without_prefix. 48/48 Modbus.Tests pass. Integration tests: DL205CoilMappingTests with three facts -- DL260_Y0_maps_to_coil_2048 (FC01 at Y0 returns ON), DL260_C0_maps_to_coil_3072 (FC01 at C0 returns ON), DL260_scratch_Crelay_supports_write_then_read (FC05 write + FC01 read round-trip at coil 4000 proves the DL-mapped coil bank is fully read/write capable end-to-end). 9/9 DL205 integration tests pass against the pymodbus dl205 profile with MODBUS_SIM_PROFILE=dl205. Caller opts into the helpers per tag the same way as PR 47's V-memory helper -- pass DirectLogicAddress.YOutputToCoil("Y0") as the ModbusTagDefinition Address; no driver-wide DL-family flag. PR 51 adds the X-input read-side integration test (there's nothing to write since X-inputs are FC02 discrete inputs, read-only); PR 52 exception-code translation; PR 53 transport reconnect-on-drop since DL260 doesn't send TCP keepalives.

This commit is contained in:
Joseph Doherty
2026-04-18 22:22:42 -04:00
parent dae29f14c8
commit b5464f11ee
4 changed files with 271 additions and 20 deletions

View File

@@ -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.
/// <summary>
/// DL260 Y-output coil base. Y0 octal → Modbus coil address 2048 (0-based).
/// </summary>
public const ushort YOutputBaseCoil = 2048;
/// <summary>
/// DL260 C-relay coil base. C0 octal → Modbus coil address 3072 (0-based).
/// </summary>
public const ushort CRelayBaseCoil = 3072;
/// <summary>
/// DL260 X-input discrete-input base. X0 octal → Modbus discrete input 0.
/// </summary>
public const ushort XInputBaseDiscrete = 0;
/// <summary>
/// DL260 SP special-relay discrete-input base. SP0 octal → Modbus discrete input 1024.
/// Read-only; writing SP relays is rejected with Illegal Data Address.
/// </summary>
public const ushort SpecialBaseDiscrete = 1024;
/// <summary>
/// Translate a DirectLOGIC Y-output address (e.g. <c>"Y0"</c>, <c>"Y17"</c>) to its
/// 0-based Modbus coil address on DL260. The trailing number is OCTAL, matching the
/// ladder-logic editor's notation.
/// </summary>
public static ushort YOutputToCoil(string yAddress) =>
AddOctalOffset(YOutputBaseCoil, StripPrefix(yAddress, 'Y'));
/// <summary>
/// Translate a DirectLOGIC C-relay address (e.g. <c>"C0"</c>, <c>"C1777"</c>) to its
/// 0-based Modbus coil address.
/// </summary>
public static ushort CRelayToCoil(string cAddress) =>
AddOctalOffset(CRelayBaseCoil, StripPrefix(cAddress, 'C'));
/// <summary>
/// Translate a DirectLOGIC X-input address (e.g. <c>"X0"</c>, <c>"X17"</c>) 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.
/// </summary>
public static ushort XInputToDiscrete(string xAddress) =>
AddOctalOffset(XInputBaseDiscrete, StripPrefix(xAddress, 'X'));
/// <summary>
/// Translate a DirectLOGIC SP-special-relay address (e.g. <c>"SP0"</c>) to its 0-based
/// Modbus discrete-input address. Accepts <c>"SP"</c> prefix case-insensitively.
/// </summary>
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;
}
}

View File

@@ -0,0 +1,109 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
/// <summary>
/// Verifies DL260 I/O-memory coil mappings against the <c>dl205.json</c> 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.
/// </summary>
[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<ModbusTagDefinition> tags)
=> new()
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags = tags,
Probe = new ModbusProbeOptions { Enabled = false },
};
}

View File

@@ -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": [],

View File

@@ -74,4 +74,66 @@ public sealed class DirectLogicAddressTests
Should.NotThrow(() => DirectLogicAddress.SystemVMemoryToPdu(0xDEFF));
Should.Throw<OverflowException>(() => 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<ArgumentException>(() =>
{
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<ArgumentException>(() => 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);
}