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);
+ }
+}