Driver.Cli.Common-007 + Driver.Cli.Common-008 resolution.
Driver.Cli.Common-007 (High, Correctness):
0x80550000 is the canonical OPC UA spec value for BadSecurityPolicyRejected,
not BadDeviceFailure. The correct spec value for BadDeviceFailure is
0x808B0000 (verified against OPC Foundation Opc.Ua.StatusCodes;
corroborated locally by Driver.Galaxy.Runtime.StatusCodeMap and both
Wonderware historian quality mappers which all hand-pin the correct
value).
The bug was duplicated across six driver modules:
- FocasStatusMapper.BadDeviceFailure
- AbCipStatusMapper.BadDeviceFailure
- AbLegacyStatusMapper.BadDeviceFailure
- TwinCATStatusMapper.BadDeviceFailure
- ModbusDriver.StatusBadDeviceFailure
- S7Driver.StatusBadDeviceFailure
Plus the SnapshotFormatter shortlist that named 0x80550000 as
BadDeviceFailure, and three downstream Modbus tests that asserted
against the wrong value (so CI was blind).
This commit fixes all six native-mapper constants, the formatter
shortlist, and the three Modbus tests in one pass. Added a regression
guard to FormatStatus_does_not_apply_pre_fix_wrong_names that pins
0x80550000 never renders as BadDeviceFailure (mirroring the existing
-001 wrong-name guards).
Behavior change: OPC UA clients consuming the native drivers now see
the canonical BadDeviceFailure (0x808B0000) on device-fault paths
instead of the misnamed BadSecurityPolicyRejected (0x80550000). Wire-
level status semantics now match operator-facing CLI labels.
Driver.Cli.Common-008 (Low, Testing):
Deleted the redundant FormatStatus_names_native_driver_emitted_codes
Theory — its five InlineData rows were already covered by the
well-known Theory in the same commit (5a9c459), and used a weaker
ShouldContain vs the well-known Theory's ShouldBe (exact match).
Verification:
- Driver.Cli.Common.Tests: 43/43 pass (was 48 after the -008 deletion).
- Driver.Modbus.Tests: 263/263 pass.
- Driver.AbCip.Tests: 262/262.
- Driver.AbLegacy.Tests: 157/157.
- Driver.FOCAS.Tests: 178/178.
- Driver.S7.Tests: 112/112.
- Driver.TwinCAT.Tests: 131/131.
Total: 1146 tests across the affected modules, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
89 lines
4.9 KiB
C#
89 lines
4.9 KiB
C#
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, 0x808B0000u)] // Server Failure → BadDeviceFailure (Driver.Cli.Common-007)
|
|
[InlineData((byte)0x05, 0x808B0000u)] // Acknowledge (long op) → BadDeviceFailure
|
|
[InlineData((byte)0x06, 0x808B0000u)] // 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(0x808B0000u, "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);
|
|
}
|
|
}
|