using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; /// /// #148 — block-coalescing auto-recovery from protected register holes. When a coalesced /// FC03 fails with a Modbus exception, the planner records the failed range and stops /// re-coalescing across it on subsequent scans. Healthy tags around the protected hole /// keep working without operator intervention. /// [Trait("Category", "Unit")] public sealed class ModbusCoalescingAutoRecoveryTests { /// /// Programmable transport that returns IllegalDataAddress (Modbus exception code 0x02) /// when a read covers a configured "protected" register address. Otherwise responds /// normally with zero-filled data of the requested size. /// private sealed class ProtectedHoleTransport : IModbusTransport { public ushort ProtectedAddress { get; set; } = ushort.MaxValue; public readonly List<(byte Fc, ushort Address, ushort Quantity)> Reads = new(); public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask; public Task SendAsync(byte unitId, byte[] pdu, CancellationToken ct) { var addr = (ushort)((pdu[1] << 8) | pdu[2]); var qty = (ushort)((pdu[3] << 8) | pdu[4]); if (pdu[0] is 0x03 or 0x04) Reads.Add((pdu[0], addr, qty)); // If the protected address falls within the request span, return a Modbus exception // PDU. The driver's transport layer detects exceptions by the high bit on the FC. if (pdu[0] is 0x03 or 0x04 && ProtectedAddress >= addr && ProtectedAddress < addr + qty) return Task.FromException(new ModbusException(pdu[0], 0x02, "IllegalDataAddress")); switch (pdu[0]) { case 0x03: case 0x04: { var resp = new byte[2 + qty * 2]; resp[0] = pdu[0]; resp[1] = (byte)(qty * 2); return Task.FromResult(resp); } default: return Task.FromResult(new byte[] { pdu[0], 0, 0 }); } } public ValueTask DisposeAsync() => ValueTask.CompletedTask; } [Fact] public async Task First_Failure_Falls_Back_To_PerTag_Same_Scan() { var fake = new ProtectedHoleTransport { ProtectedAddress = 102 }; // Three tags: 100, 102 (protected), 104. With MaxReadGap=5, the coalesced block is // 100..104 — covers the protected register, so FC03 quantity=5 fails. Pre-#148 marked // ALL three Bad. Post-#148, the failure auto-falls back to per-tag in the same scan // so 100 and 104 still surface Good values. var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16); var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16); var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16); var opts = new ModbusDriverOptions { Host = "f", Tags = [t100, t102, t104], MaxReadGap = 5, Probe = new ModbusProbeOptions { Enabled = false } }; var drv = new ModbusDriver(opts, "m1", _ => fake); await drv.InitializeAsync("{}", CancellationToken.None); var values = await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None); // T100 + T104 should fall through per-tag and succeed; T102 is the protected register // and surfaces the exception status code at single-tag granularity. values[0].StatusCode.ShouldBe(0u, "T100 should succeed via per-tag fallback"); values[2].StatusCode.ShouldBe(0u, "T104 should succeed via per-tag fallback"); values[1].StatusCode.ShouldNotBe(0u, "T102 is the protected address — single-tag read still surfaces the exception"); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task Second_Scan_Skips_Coalesced_Read_Of_Prohibited_Range() { var fake = new ProtectedHoleTransport { ProtectedAddress = 102 }; var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16); var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16); var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16); var opts = new ModbusDriverOptions { Host = "f", Tags = [t100, t102, t104], MaxReadGap = 5, Probe = new ModbusProbeOptions { Enabled = false } }; var drv = new ModbusDriver(opts, "m1", _ => fake); await drv.InitializeAsync("{}", CancellationToken.None); // Scan 1: planner forms 100..104 block, fails, records the prohibition. await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None); drv.AutoProhibitedRangeCount.ShouldBe(1); var scan1Reads = fake.Reads.Count; // Scan 2: planner sees the prohibition, doesn't form the 100..104 block, falls back to // per-tag for everyone. Total scan-2 PDUs: 3 (one per tag) — vs 1 failed coalesced // read + 3 per-tag fallbacks if we re-tried the merge. fake.Reads.Clear(); await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None); var coalescedAttemptedAgain = fake.Reads.Any(r => r.Address == 100 && r.Quantity > 1); coalescedAttemptedAgain.ShouldBeFalse("planner must NOT re-attempt the prohibited block"); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task Reprobe_Clears_Prohibition_When_Range_Becomes_Healthy() { // #151 — when AutoProhibitReprobeInterval is set, the background loop retries each // prohibition periodically. We exercise that via the test-only RunReprobeOnceForTestAsync // helper rather than waiting for the timer (which would slow the suite). var fake = new ProtectedHoleTransport { ProtectedAddress = 102 }; var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16); var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16); var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16); var opts = new ModbusDriverOptions { Host = "f", Tags = [t100, t102, t104], MaxReadGap = 5, AutoProhibitReprobeInterval = TimeSpan.FromMilliseconds(100), Probe = new ModbusProbeOptions { Enabled = false } }; var drv = new ModbusDriver(opts, "m1", _ => fake); await drv.InitializeAsync("{}", CancellationToken.None); // Scan 1: coalesced read fails, prohibition recorded. await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None); drv.AutoProhibitedRangeCount.ShouldBe(1); // Operator unlocks the protected register at the PLC (firmware update etc.). The // re-probe should now succeed and clear the prohibition. fake.ProtectedAddress = ushort.MaxValue; await drv.RunReprobeOnceForTestAsync(CancellationToken.None); drv.AutoProhibitedRangeCount.ShouldBe(0, "re-probe must clear the prohibition once the range is healthy"); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task Reprobe_Leaves_Prohibition_When_Range_Is_Still_Bad() { var fake = new ProtectedHoleTransport { ProtectedAddress = 102 }; var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16); var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16); var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16); var opts = new ModbusDriverOptions { Host = "f", Tags = [t100, t102, t104], MaxReadGap = 5, AutoProhibitReprobeInterval = TimeSpan.FromMilliseconds(100), Probe = new ModbusProbeOptions { Enabled = false } }; var drv = new ModbusDriver(opts, "m1", _ => fake); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None); drv.AutoProhibitedRangeCount.ShouldBe(1); // Re-probe with the protected register still bad — prohibition stays. await drv.RunReprobeOnceForTestAsync(CancellationToken.None); drv.AutoProhibitedRangeCount.ShouldBe(1, "re-probe failure must keep the prohibition in place"); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task GetAutoProhibitedRanges_Surfaces_Operator_Visible_Snapshot() { // #152 — diagnostic accessor returns the live prohibition map as a snapshot of public // ModbusAutoProhibition records. Consumers (Admin UI, dashboards) project this list // into whatever shape they need. var fake = new ProtectedHoleTransport { ProtectedAddress = 102 }; var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16); var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16); var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16); var opts = new ModbusDriverOptions { Host = "f", UnitId = 7, Tags = [t100, t102, t104], MaxReadGap = 5, Probe = new ModbusProbeOptions { Enabled = false } }; var drv = new ModbusDriver(opts, "m1", _ => fake); await drv.InitializeAsync("{}", CancellationToken.None); // Pre-failure: nothing prohibited. drv.GetAutoProhibitedRanges().ShouldBeEmpty(); await drv.ReadAsync(["T100", "T102", "T104"], CancellationToken.None); var snapshot = drv.GetAutoProhibitedRanges(); snapshot.Count.ShouldBe(1); snapshot[0].UnitId.ShouldBe((byte)7); snapshot[0].Region.ShouldBe(ModbusRegion.HoldingRegisters); snapshot[0].StartAddress.ShouldBe((ushort)100); snapshot[0].EndAddress.ShouldBe((ushort)104); snapshot[0].BisectionPending.ShouldBeTrue("multi-register prohibition starts split-pending"); snapshot[0].LastProbedUtc.ShouldBeGreaterThan(DateTime.UtcNow.AddMinutes(-1)); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task Tags_Outside_Prohibited_Range_Still_Coalesce() { var fake = new ProtectedHoleTransport { ProtectedAddress = 102 }; // Tags split across the protected boundary: cluster 100..104 (will fail) and cluster // 200..204 (well clear of the protected register). The 200-cluster should keep // coalescing on subsequent scans even after the 100-cluster is prohibited. var t100 = new ModbusTagDefinition("T100", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16); var t102 = new ModbusTagDefinition("T102", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16); var t104 = new ModbusTagDefinition("T104", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16); var t200 = new ModbusTagDefinition("T200", ModbusRegion.HoldingRegisters, 200, ModbusDataType.Int16); var t202 = new ModbusTagDefinition("T202", ModbusRegion.HoldingRegisters, 202, ModbusDataType.Int16); var opts = new ModbusDriverOptions { Host = "f", Tags = [t100, t102, t104, t200, t202], MaxReadGap = 5, Probe = new ModbusProbeOptions { Enabled = false } }; var drv = new ModbusDriver(opts, "m1", _ => fake); await drv.InitializeAsync("{}", CancellationToken.None); await drv.ReadAsync(["T100", "T102", "T104", "T200", "T202"], CancellationToken.None); fake.Reads.Clear(); await drv.ReadAsync(["T100", "T102", "T104", "T200", "T202"], CancellationToken.None); // The 200..202 block should still coalesce — its range doesn't overlap the // 100..104 prohibition. var coalesced200Block = fake.Reads.Any(r => r.Address == 200 && r.Quantity == 3); coalesced200Block.ShouldBeTrue("the 200..202 block must keep coalescing — it's outside the prohibited range"); await drv.ShutdownAsync(CancellationToken.None); } }