using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests; /// /// End-to-end verification that the driver's MapModbusExceptionToStatus /// 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 exception_injector.py server (exception_injection /// compose profile) at each of the rule addresses in /// Docker/profiles/exception_injection.json and asserts the driver surfaces /// the expected OPC UA StatusCode. /// /// /// 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). /// [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 = 0x80550000u; 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> 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); } }