Compare commits

...

8 Commits

Author SHA1 Message Date
Joseph Doherty
cde018aec1 Phase 3 PR 52 -- Modbus exception-code -> OPC UA StatusCode translation. Before this PR every server-side Modbus exception AND every transport-layer failure collapsed to BadInternalError (0x80020000) in the driver's Read/Write results, making field diagnosis 'is this a tag misconfig or a driver bug?' impossible from the OPC UA client side. PR 52 adds a MapModbusExceptionToStatus helper that translates per spec: 01 Illegal Function -> BadNotSupported (0x803D0000); 02 Illegal Data Address -> BadOutOfRange (0x803C0000); 03 Illegal Data Value -> BadOutOfRange; 04 Server Failure -> BadDeviceFailure (0x80550000); 05/06 Acknowledge/Busy -> BadDeviceFailure; 0A/0B Gateway -> BadCommunicationError (0x80050000); unknown -> BadInternalError fallback. Non-Modbus failures (socket drop, timeout, malformed frame) in ReadAsync are now distinguished from tag-level faults: they map to BadCommunicationError so operators check network/PLC reachability rather than tag definitions. Why per-DL205: docs/v2/dl205.md documents DL205/DL260 returning only codes 01-04 with specific triggers -- exception 04 specifically means 'CPU in PROGRAM mode during a protected write', which is operator-recoverable by switching the CPU to RUN; surfacing it as BadDeviceFailure (not BadInternalError) makes the fix obvious. Changes in ModbusDriver: Read catch-chain now ModbusException first (-> mapper), generic Exception second (-> BadCommunicationError); Write catch-chain same pattern but generic Exception stays BadInternalError because write failures can legitimately come from EncodeRegister (out-of-range value) which is a driver-layer fault. Unit tests: MapModbusExceptionToStatus theory exercising every code in the table including the 0xFF fallback; Read_surface_exception_02_as_BadOutOfRange with an ExceptionRaisingTransport that forces code 02; Write_surface_exception_04_as_BadDeviceFailure for CPU-mode faults; Read_non_modbus_failure_maps_to_BadCommunicationError with a NonModbusFailureTransport that raises EndOfStreamException. 115/115 Modbus.Tests pass. Integration test: DL205ExceptionCodeTests.DL205_FC03_at_unmapped_register_returns_BadOutOfRange reads HR[16383] which is beyond the seeded uint16 cells on the dl205.json profile; pymodbus returns exception 02 and the driver surfaces BadOutOfRange. 11/11 DL205 integration tests pass with MODBUS_SIM_PROFILE=dl205. 2026-04-18 22:28:37 -04:00
Joseph Doherty
9892a0253d Phase 3 PR 51 -- DL260 X-input FC02 discrete-input mapping end-to-end test. Integration test DL205XInputTests reads FC02 at the DirectLogicAddress.XInputToDiscrete-resolved address and asserts two behaviors against the dl205.json pymodbus profile: (1) X20 octal (=decimal 16 = Modbus DI 16) reads ON, proving the helper correctly octal-parses the trailing number and adds it to the 0 base; (2) X21 octal reads OFF (not exception) -- per docs/v2/dl205.md §I/O-mapping, 'reading a non-populated X input returns zero, not an exception' on DL260, because the CPU sizes the discrete-input table to the configured I/O not the installed hardware. Pymodbus models this by returning the default 0 value for any DI bit in the configured 'di size' range that wasn't explicitly seeded, matching real DL260 behaviour. Test uses X20 rather than X0 to sidestep a shared-blocks conflict: pymodbus places FC01/FC02 bit-address 0..15 into cell 0, but cell 0 is already uint16-typed (V0 marker = 0xCAFE) per the register-zero quirk test, and shared-blocks semantics allow only one type per cell. X20 octal = DI 16 lands in cell 1 which is free, so both the V0 quirk AND the X-input quirk can coexist in one profile. dl205.json: bits cell 1 seeded value=9 (bits 0 and 3 set -> X20, X23 octal = ON), write-range extended to include cell 1 (though X-inputs are read-only; the write-range entry is required by pymodbus for ANY cell referenced in a bits section even if only reads are expected -- pymodbus validates write-access uniformly). 10/10 DL205 integration tests pass with MODBUS_SIM_PROFILE=dl205. No driver code changes -- the XInputToDiscrete helper + FC02 read path already landed in PRs 50 and 21 respectively. This PR closes the integration-test gap that docs/v2/dl205.md called out under test name DL205_Xinput_unpopulated_reads_as_zero. 2026-04-18 22:25:13 -04:00
Joseph Doherty
b5464f11ee 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. 2026-04-18 22:22:42 -04:00
dae29f14c8 Merge pull request 'Phase 3 PR 49 -- Per-device FC03/FC16 register caps with auto-chunking' (#48) from phase-3-pr49-dl205-fc-caps into v2 2026-04-18 22:13:46 -04:00
f306793e36 Merge pull request 'Phase 3 PR 48 -- DL205 CDAB float word order end-to-end test' (#47) from phase-3-pr48-dl205-cdab-float into v2 2026-04-18 22:13:39 -04:00
9e61873cc0 Merge pull request 'Phase 3 PR 47 -- DL205 V-memory octal-address helper' (#46) from phase-3-pr47-dl205-vmemory into v2 2026-04-18 22:13:32 -04:00
1a60470d4a Merge pull request 'Phase 3 PR 46 -- DL205 BCD decoder' (#45) from phase-3-pr46-dl205-bcd into v2 2026-04-18 22:13:24 -04:00
635f67bb02 Merge pull request 'Phase 3 PR 45 -- DL205 string byte-order quirk' (#44) from phase-3-pr45-dl205-string-byte-order into v2 2026-04-18 22:12:15 -04:00
8 changed files with 524 additions and 21 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

@@ -141,9 +141,16 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
results[i] = new DataValueSnapshot(value, 0u, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (ModbusException mex)
{
results[i] = new DataValueSnapshot(null, MapModbusExceptionToStatus(mex.ExceptionCode), null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, mex.Message);
}
catch (Exception ex)
{
results[i] = new DataValueSnapshot(null, StatusBadInternalError, null, now);
// Non-Modbus-layer failure: socket dropped, timeout, malformed response. Surface
// as communication error so callers can distinguish it from tag-level faults.
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
@@ -238,6 +245,10 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
await WriteOneAsync(transport, tag, w.Value, cancellationToken).ConfigureAwait(false);
results[i] = new WriteResult(0u);
}
catch (ModbusException mex)
{
results[i] = new WriteResult(MapModbusExceptionToStatus(mex.ExceptionCode));
}
catch (Exception)
{
results[i] = new WriteResult(StatusBadInternalError);
@@ -686,6 +697,31 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
private const uint StatusBadInternalError = 0x80020000u;
private const uint StatusBadNodeIdUnknown = 0x80340000u;
private const uint StatusBadNotWritable = 0x803B0000u;
private const uint StatusBadOutOfRange = 0x803C0000u;
private const uint StatusBadNotSupported = 0x803D0000u;
private const uint StatusBadDeviceFailure = 0x80550000u;
private const uint StatusBadCommunicationError = 0x80050000u;
/// <summary>
/// Map a server-returned Modbus exception code to the most informative OPC UA
/// StatusCode. Keeps the driver's outward-facing status surface aligned with what a
/// Modbus engineer would expect when reading the spec: exception 02 (Illegal Data
/// Address) surfaces as BadOutOfRange so clients can distinguish "tag wrong" from
/// generic BadInternalError, exception 04 (Server Failure) as BadDeviceFailure so
/// operators see a CPU-mode problem rather than a driver bug, etc. Per
/// <c>docs/v2/dl205.md</c>, DL205/DL260 returns only codes 01-04 — no proprietary
/// extensions.
/// </summary>
internal static uint MapModbusExceptionToStatus(byte exceptionCode) => exceptionCode switch
{
0x01 => StatusBadNotSupported, // Illegal Function — FC not in supported list
0x02 => StatusBadOutOfRange, // Illegal Data Address — register outside mapped range
0x03 => StatusBadOutOfRange, // Illegal Data Value — quantity over per-FC cap
0x04 => StatusBadDeviceFailure, // Server Failure — CPU in PROGRAM mode during protected write
0x05 or 0x06 => StatusBadDeviceFailure, // Acknowledge / Server Busy — long-running op / busy
0x0A or 0x0B => StatusBadCommunicationError, // Gateway path unavailable / target failed to respond
_ => StatusBadInternalError,
};
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
public async ValueTask DisposeAsync()

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

@@ -0,0 +1,53 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
/// <summary>
/// Verifies the driver's Modbus-exception → OPC UA StatusCode translation end-to-end
/// against the dl205.json pymodbus profile. pymodbus returns exception 02 (Illegal Data
/// Address) for reads outside the configured register ranges, matching real DL205/DL260
/// firmware behavior per <c>docs/v2/dl205.md</c> §exception-codes. The driver must surface
/// that as <c>BadOutOfRange</c> (0x803C0000) — not <c>BadInternalError</c> — so the
/// operator sees a tag-config diagnosis instead of a generic driver-fault message.
/// </summary>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "DL205")]
public sealed class DL205ExceptionCodeTests(ModbusSimulatorFixture sim)
{
[Fact]
public async Task DL205_FC03_at_unmapped_register_returns_BadOutOfRange()
{
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.");
}
// Address 16383 is the last cell of hr-size=16384 in dl205.json; address 16384 is
// beyond the configured HR range. pymodbus validates and returns exception 02
// (Illegal Data Address).
var options = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags =
[
new ModbusTagDefinition("Unmapped",
ModbusRegion.HoldingRegisters, Address: 16383,
DataType: ModbusDataType.UInt16, Writable: false),
],
Probe = new ModbusProbeOptions { Enabled = false },
};
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-exc");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["Unmapped"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0x803C0000u,
"DL205 returns exception 02 for an FC03 at an unmapped register; driver must translate to BadOutOfRange (not BadInternalError)");
}
}

View File

@@ -0,0 +1,71 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
/// <summary>
/// Verifies the DL260 X-input discrete-input mapping against the <c>dl205.json</c>
/// pymodbus profile. X-inputs are FC02 discrete-input-only (Modbus doesn't allow writes
/// to discrete inputs), and the DirectLOGIC convention is X0 → DI 0 with octal offsets
/// for subsequent addresses. The sim seeds X20 octal (= DI 16) = ON so the test can
/// prove the helper routes through to the right cell.
/// </summary>
/// <remarks>
/// X0 / X1 / …X17 octal all share cell 0 (DI 0-15 → cell 0 bits 0-15) which conflicts
/// with the V0 uint16 marker; we can't seed both types at cell 0 under shared-blocks
/// semantics. So the test uses X20 octal (first address beyond the cell-0 boundary) which
/// lands cleanly at cell 1 bit 0 and leaves the V0 register-zero quirk intact.
/// </remarks>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "DL205")]
public sealed class DL205XInputTests(ModbusSimulatorFixture sim)
{
[Fact]
public async Task DL260_X20_octal_maps_to_DiscreteInput_16_and_reads_ON()
{
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.");
}
// X20 octal = decimal 16 = DI 16 per the DL260 convention (X-inputs start at DI 0).
var di = DirectLogicAddress.XInputToDiscrete("X20");
di.ShouldBe((ushort)16);
var options = BuildOptions(sim, [
new ModbusTagDefinition("DL260_X20",
ModbusRegion.DiscreteInputs, Address: di,
DataType: ModbusDataType.Bool, Writable: false),
// Unpopulated-X control: pymodbus returns 0 (not exception) for any bit in the
// configured DI range that wasn't explicitly seeded — per docs/v2/dl205.md
// "Reading a non-populated X input ... returns zero, not an exception".
new ModbusTagDefinition("DL260_X21_off",
ModbusRegion.DiscreteInputs, Address: DirectLogicAddress.XInputToDiscrete("X21"),
DataType: ModbusDataType.Bool, Writable: false),
]);
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-xinput");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["DL260_X20", "DL260_X21_off"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe(true, "dl205.json seeds cell 1 bit 0 (X20 octal = DI 16) = ON");
results[1].StatusCode.ShouldBe(0u, "unpopulated X inputs must read cleanly — DL260 does NOT raise an exception");
results[1].Value.ShouldBe(false);
}
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,10 @@
[1280, 1282],
[1343, 1343],
[1407, 1407],
[2048, 2050],
[3072, 3074],
[4000, 4007],
[1, 1],
[128, 128],
[192, 192],
[250, 250],
[8448, 8448]
],
@@ -88,25 +89,17 @@
],
"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": "X-input bank marker cell. X0 -> DI 0 conflicts with uint16 V0 at cell 0, so this marker covers X20 octal (= decimal 16 = DI 16 = cell 1 bit 0). X20=ON, X23 octal (DI 19 = cell 1 bit 3)=ON -> cell 1 value = 0b00001001 = 9.",
"addr": 1, "value": 9},
{"_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": "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": "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": "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 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);
}

View File

@@ -0,0 +1,88 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Unit tests for the Modbus-exception-code → OPC UA StatusCode mapping added in PR 52.
/// Before PR 52 every server exception + every transport failure collapsed to
/// BadInternalError (0x80020000), which made field diagnosis "is this a bad tag or a bad
/// driver?" impossible. These tests lock in the translation table documented on
/// <see cref="ModbusDriver.MapModbusExceptionToStatus"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusExceptionMapperTests
{
[Theory]
[InlineData((byte)0x01, 0x803D0000u)] // Illegal Function → BadNotSupported
[InlineData((byte)0x02, 0x803C0000u)] // Illegal Data Address → BadOutOfRange
[InlineData((byte)0x03, 0x803C0000u)] // Illegal Data Value → BadOutOfRange
[InlineData((byte)0x04, 0x80550000u)] // Server Failure → BadDeviceFailure
[InlineData((byte)0x05, 0x80550000u)] // Acknowledge (long op) → BadDeviceFailure
[InlineData((byte)0x06, 0x80550000u)] // Server Busy → BadDeviceFailure
[InlineData((byte)0x0A, 0x80050000u)] // Gateway path unavailable → BadCommunicationError
[InlineData((byte)0x0B, 0x80050000u)] // Gateway target failed to respond → BadCommunicationError
[InlineData((byte)0xFF, 0x80020000u)] // Unknown code → BadInternalError fallback
public void MapModbusExceptionToStatus_returns_informative_status(byte code, uint expected)
=> ModbusDriver.MapModbusExceptionToStatus(code).ShouldBe(expected);
private sealed class ExceptionRaisingTransport(byte exceptionCode) : IModbusTransport
{
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
=> Task.FromException<byte[]>(new ModbusException(pdu[0], exceptionCode, $"fc={pdu[0]} code={exceptionCode}"));
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Fact]
public async Task Read_surface_exception_02_as_BadOutOfRange_not_BadInternalError()
{
var transport = new ExceptionRaisingTransport(exceptionCode: 0x02);
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.ReadAsync(["T"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0x803C0000u, "FC03 at an unmapped register must bubble out as BadOutOfRange so operators can spot a bad tag config");
}
[Fact]
public async Task Write_surface_exception_04_as_BadDeviceFailure()
{
var transport = new ExceptionRaisingTransport(exceptionCode: 0x04);
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var writes = await drv.WriteAsync(
[new WriteRequest("T", (short)42)],
TestContext.Current.CancellationToken);
writes[0].StatusCode.ShouldBe(0x80550000u, "FC06 returning exception 04 (CPU in PROGRAM mode) maps to BadDeviceFailure");
}
private sealed class NonModbusFailureTransport : IModbusTransport
{
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
=> Task.FromException<byte[]>(new EndOfStreamException("socket closed mid-response"));
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Fact]
public async Task Read_non_modbus_failure_maps_to_BadCommunicationError_not_BadInternalError()
{
// Socket drop / timeout / malformed frame → transport-layer failure. Should surface
// distinctly from tag-level faults so operators know to check the network, not the config.
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
await using var drv = new ModbusDriver(opts, "modbus-1", _ => new NonModbusFailureTransport());
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.ReadAsync(["T"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0x80050000u);
}
}