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>
123 lines
5.8 KiB
C#
123 lines
5.8 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// End-to-end verification that the driver's <c>MapModbusExceptionToStatus</c>
|
|
/// translation is wire-correct for every exception code in the mapping table —
|
|
/// not just 0x02, which is the only code the pymodbus simulator naturally emits.
|
|
/// Drives the standalone <c>exception_injector.py</c> server (<c>exception_injection</c>
|
|
/// compose profile) at each of the rule addresses in
|
|
/// <c>Docker/profiles/exception_injection.json</c> and asserts the driver surfaces
|
|
/// the expected OPC UA StatusCode.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Why integration coverage on top of the unit tests: the unit tests prove the
|
|
/// translation function is correct; these prove the driver wires it through on
|
|
/// the read + write paths unchanged, after the MBAP header + PDU round-trip
|
|
/// (where a subtle framing bug could swallow or misclassify the exception).
|
|
/// </remarks>
|
|
[Collection(ModbusSimulatorCollection.Name)]
|
|
[Trait("Category", "Integration")]
|
|
[Trait("Device", "ExceptionInjection")]
|
|
public sealed class ExceptionInjectionTests(ModbusSimulatorFixture sim)
|
|
{
|
|
private const uint StatusGood = 0u;
|
|
private const uint StatusBadOutOfRange = 0x803C0000u;
|
|
private const uint StatusBadNotSupported = 0x803D0000u;
|
|
private const uint StatusBadDeviceFailure = 0x808B0000u;
|
|
private const uint StatusBadCommunicationError = 0x80050000u;
|
|
|
|
private void SkipUnlessInjectorLive()
|
|
{
|
|
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
|
var profile = Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE");
|
|
if (!string.Equals(profile, "exception_injection", StringComparison.OrdinalIgnoreCase))
|
|
Assert.Skip("MODBUS_SIM_PROFILE != exception_injection — skipping. " +
|
|
"Start the fixture with --profile exception_injection.");
|
|
}
|
|
|
|
private async Task<IReadOnlyList<DataValueSnapshot>> ReadSingleAsync(int address, string tagName)
|
|
{
|
|
var opts = new ModbusDriverOptions
|
|
{
|
|
Host = sim.Host,
|
|
Port = sim.Port,
|
|
UnitId = 1,
|
|
Timeout = TimeSpan.FromSeconds(2),
|
|
Tags =
|
|
[
|
|
new ModbusTagDefinition(tagName,
|
|
ModbusRegion.HoldingRegisters, Address: (ushort)address,
|
|
DataType: ModbusDataType.UInt16, Writable: false),
|
|
],
|
|
Probe = new ModbusProbeOptions { Enabled = false },
|
|
};
|
|
await using var driver = new ModbusDriver(opts, driverInstanceId: "modbus-exc");
|
|
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
return await driver.ReadAsync([tagName], TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1000, StatusBadNotSupported, "exc 0x01 (Illegal Function) -> BadNotSupported")]
|
|
[InlineData(1001, StatusBadOutOfRange, "exc 0x02 (Illegal Data Address) -> BadOutOfRange")]
|
|
[InlineData(1002, StatusBadOutOfRange, "exc 0x03 (Illegal Data Value) -> BadOutOfRange")]
|
|
[InlineData(1003, StatusBadDeviceFailure, "exc 0x04 (Server Failure) -> BadDeviceFailure")]
|
|
[InlineData(1004, StatusBadDeviceFailure, "exc 0x05 (Acknowledge / long op) -> BadDeviceFailure")]
|
|
[InlineData(1005, StatusBadDeviceFailure, "exc 0x06 (Server Busy) -> BadDeviceFailure")]
|
|
[InlineData(1006, StatusBadCommunicationError, "exc 0x0A (Gateway Path Unavailable) -> BadCommunicationError")]
|
|
[InlineData(1007, StatusBadCommunicationError, "exc 0x0B (Gateway Target No Response) -> BadCommunicationError")]
|
|
public async Task FC03_read_at_injection_address_surfaces_expected_status(
|
|
int address, uint expectedStatus, string scenario)
|
|
{
|
|
SkipUnlessInjectorLive();
|
|
var results = await ReadSingleAsync(address, $"Injected_{address}");
|
|
results[0].StatusCode.ShouldBe(expectedStatus, scenario);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FC03_read_at_non_injected_address_returns_Good()
|
|
{
|
|
// Sanity: HR[0..31] are seeded with address-as-value in the profile. A read at
|
|
// one of those addresses must come back Good (0) — otherwise the injector is
|
|
// misbehaving and every other assertion in this class is uninformative.
|
|
SkipUnlessInjectorLive();
|
|
var results = await ReadSingleAsync(address: 5, tagName: "Healthy_5");
|
|
results[0].StatusCode.ShouldBe(StatusGood);
|
|
results[0].Value.ShouldBe((ushort)5);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(2000, StatusBadDeviceFailure, "exc 0x04 on FC06 -> BadDeviceFailure (CPU in PROGRAM mode)")]
|
|
[InlineData(2001, StatusBadDeviceFailure, "exc 0x06 on FC06 -> BadDeviceFailure (Server Busy)")]
|
|
public async Task FC06_write_at_injection_address_surfaces_expected_status(
|
|
int address, uint expectedStatus, string scenario)
|
|
{
|
|
SkipUnlessInjectorLive();
|
|
var tag = $"InjectedWrite_{address}";
|
|
var opts = new ModbusDriverOptions
|
|
{
|
|
Host = sim.Host,
|
|
Port = sim.Port,
|
|
UnitId = 1,
|
|
Timeout = TimeSpan.FromSeconds(2),
|
|
Tags =
|
|
[
|
|
new ModbusTagDefinition(tag,
|
|
ModbusRegion.HoldingRegisters, Address: (ushort)address,
|
|
DataType: ModbusDataType.UInt16, Writable: true),
|
|
],
|
|
Probe = new ModbusProbeOptions { Enabled = false },
|
|
};
|
|
await using var driver = new ModbusDriver(opts, driverInstanceId: "modbus-exc-write");
|
|
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
|
|
var writes = await driver.WriteAsync(
|
|
[new WriteRequest(tag, (ushort)42)],
|
|
TestContext.Current.CancellationToken);
|
|
writes[0].StatusCode.ShouldBe(expectedStatus, scenario);
|
|
}
|
|
}
|