From cde018aec1cab2b3b1a4733bd320d0381ee0f525 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 22:28:37 -0400 Subject: [PATCH] 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. --- .../ModbusDriver.cs | 38 +++++++- .../DL205/DL205ExceptionCodeTests.cs | 53 +++++++++++ .../ModbusExceptionMapperTests.cs | 88 +++++++++++++++++++ 3 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205ExceptionCodeTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusExceptionMapperTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs index a4a87c6..12e2421 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs @@ -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; + + /// + /// 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 + /// docs/v2/dl205.md, DL205/DL260 returns only codes 01-04 — no proprietary + /// extensions. + /// + 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() diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205ExceptionCodeTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205ExceptionCodeTests.cs new file mode 100644 index 0000000..f61ea65 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/DL205/DL205ExceptionCodeTests.cs @@ -0,0 +1,53 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205; + +/// +/// 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 docs/v2/dl205.md §exception-codes. The driver must surface +/// that as BadOutOfRange (0x803C0000) — not BadInternalError — so the +/// operator sees a tag-config diagnosis instead of a generic driver-fault message. +/// +[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)"); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusExceptionMapperTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusExceptionMapperTests.cs new file mode 100644 index 0000000..c329400 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusExceptionMapperTests.cs @@ -0,0 +1,88 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; + +/// +/// 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 +/// . +/// +[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 SendAsync(byte unitId, byte[] pdu, CancellationToken ct) + => Task.FromException(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 SendAsync(byte unitId, byte[] pdu, CancellationToken ct) + => Task.FromException(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); + } +}