diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs index c33d911..9fd71d5 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs @@ -136,6 +136,13 @@ public sealed class ModbusDriver _probeCts = new CancellationTokenSource(); _ = Task.Run(() => ProbeLoopAsync(_probeCts.Token), _probeCts.Token); } + + // #151 — start the auto-prohibition re-probe loop when the operator opted in. + if (_options.AutoProhibitReprobeInterval is not null) + { + _reprobeCts = new CancellationTokenSource(); + _ = Task.Run(() => ReprobeLoopAsync(_reprobeCts.Token), _reprobeCts.Token); + } } catch (Exception ex) { @@ -156,6 +163,15 @@ public sealed class ModbusDriver _probeCts?.Dispose(); _probeCts = null; + try { _reprobeCts?.Cancel(); } catch { } + _reprobeCts?.Dispose(); + _reprobeCts = null; + + // #151 — clear the prohibition set on shutdown so an explicit operator restart + // (ReinitializeAsync) starts with a clean slate. The re-probe loop already retries + // automatically when enabled; the restart path is the manual escape hatch. + lock (_autoProhibitedLock) _autoProhibited.Clear(); + await _poll.DisposeAsync().ConfigureAwait(false); if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false); @@ -393,14 +409,15 @@ public sealed class ModbusDriver /// Cleared by ReinitializeAsync (operator restart) or by an explicit re-probe API /// (not yet shipped). /// - private readonly HashSet<(byte Unit, ModbusRegion Region, ushort Start, ushort End)> _autoProhibited = new(); + private readonly Dictionary<(byte Unit, ModbusRegion Region, ushort Start, ushort End), DateTime> _autoProhibited = new(); private readonly object _autoProhibitedLock = new(); + private CancellationTokenSource? _reprobeCts; private bool RangeIsAutoProhibited(byte unit, ModbusRegion region, ushort start, ushort end) { lock (_autoProhibitedLock) { - foreach (var p in _autoProhibited) + foreach (var p in _autoProhibited.Keys) { // A candidate (start..end) range is prohibited if it overlaps any recorded // failure. Overlap rule: max-start ≤ min-end. We don't try to be smart about @@ -414,7 +431,7 @@ public sealed class ModbusDriver private void RecordAutoProhibition(byte unit, ModbusRegion region, ushort start, ushort end) { - lock (_autoProhibitedLock) _autoProhibited.Add((unit, region, start, end)); + lock (_autoProhibitedLock) _autoProhibited[(unit, region, start, end)] = DateTime.UtcNow; } /// Test/diagnostic accessor — returns the current auto-prohibited range count. @@ -423,6 +440,81 @@ public sealed class ModbusDriver get { lock (_autoProhibitedLock) return _autoProhibited.Count; } } + /// + /// #151 — periodic re-probe loop. Wakes every AutoProhibitReprobeInterval and + /// retries each auto-prohibited range with a one-shot coalesced read. Successful + /// re-probes drop the prohibition; failed ones leave it in place + bump the + /// last-probed timestamp so the next attempt waits another full interval. + /// Lives for the driver lifetime; cancelled by ShutdownAsync. + /// + private async Task ReprobeLoopAsync(CancellationToken ct) + { + var interval = _options.AutoProhibitReprobeInterval!.Value; + var transport = _transport; + while (!ct.IsCancellationRequested) + { + try { await Task.Delay(interval, ct).ConfigureAwait(false); } + catch (OperationCanceledException) { return; } + + if (transport is null) continue; + + // Snapshot the prohibition set so we can release the lock during the wire calls. + (byte Unit, ModbusRegion Region, ushort Start, ushort End)[] candidates; + lock (_autoProhibitedLock) + candidates = _autoProhibited.Keys.ToArray(); + + foreach (var p in candidates) + { + if (ct.IsCancellationRequested) return; + var fc = p.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04; + var qty = (ushort)(p.End - p.Start + 1); + try + { + using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + probeCts.CancelAfter(_options.Timeout); + _ = await ReadRegisterBlockAsync(transport, p.Unit, fc, p.Start, qty, probeCts.Token).ConfigureAwait(false); + // Range is healthy now — drop the prohibition. Next data scan re-coalesces normally. + lock (_autoProhibitedLock) _autoProhibited.Remove(p); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; } + catch + { + // Still bad. Bump the timestamp so it shows up on diagnostics as recently + // re-probed — the prohibition stays in place. + lock (_autoProhibitedLock) + { + if (_autoProhibited.ContainsKey(p)) + _autoProhibited[p] = DateTime.UtcNow; + } + } + } + } + } + + /// Test/diagnostic accessor — fires one re-probe pass synchronously for tests. + internal async Task RunReprobeOnceForTestAsync(CancellationToken ct) + { + var transport = _transport ?? throw new InvalidOperationException("Transport not connected"); + (byte Unit, ModbusRegion Region, ushort Start, ushort End)[] candidates; + lock (_autoProhibitedLock) candidates = _autoProhibited.Keys.ToArray(); + foreach (var p in candidates) + { + var fc = p.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04; + var qty = (ushort)(p.End - p.Start + 1); + try + { + _ = await ReadRegisterBlockAsync(transport, p.Unit, fc, p.Start, qty, ct).ConfigureAwait(false); + lock (_autoProhibitedLock) _autoProhibited.Remove(p); + } + catch + { + lock (_autoProhibitedLock) + if (_autoProhibited.ContainsKey(p)) + _autoProhibited[p] = DateTime.UtcNow; + } + } + } + /// /// #143 block-read coalescing planner. Groups eligible tags by (UnitId, Region), sorts /// by start address, and merges adjacent / near-adjacent (gap ≤ MaxReadGap) into single diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs index 276ebcf..5969597 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs @@ -50,6 +50,7 @@ public static class ModbusDriverFactoryExtensions : ParseEnum(dto.Family, "", driverInstanceId, "Family"), MelsecSubFamily = dto.MelsecSubFamily is null ? MelsecFamily.Q_L_iQR : ParseEnum(dto.MelsecSubFamily, "", driverInstanceId, "MelsecSubFamily"), + AutoProhibitReprobeInterval = dto.AutoProhibitReprobeMs is { } reprobeMs ? TimeSpan.FromMilliseconds(reprobeMs) : null, AutoReconnect = dto.AutoReconnect ?? true, Tags = dto.Tags is { Count: > 0 } ? [.. dto.Tags.Select(t => BuildTag( @@ -175,6 +176,7 @@ public static class ModbusDriverFactoryExtensions public bool? WriteOnChangeOnly { get; init; } public string? Family { get; init; } public string? MelsecSubFamily { get; init; } + public int? AutoProhibitReprobeMs { get; init; } public bool? AutoReconnect { get; init; } public List? Tags { get; init; } public ModbusProbeDto? Probe { get; init; } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs index c8eff91..e394ec5 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs @@ -79,6 +79,16 @@ public sealed class ModbusDriverOptions /// public bool DisableFC23 { get; init; } = false; + /// + /// #151 — interval for the background re-probe loop that retries auto-prohibited + /// coalesced ranges (#148). When non-null, every AutoProhibitReprobeInterval + /// the driver attempts each prohibition's coalesced read once. If the re-probe + /// succeeds, the prohibition clears and the planner resumes coalescing across the + /// range on the next scan. Default null = re-probe disabled (prohibitions + /// persist until ReinitializeAsync; preserves pre-#151 behaviour). + /// + public TimeSpan? AutoProhibitReprobeInterval { get; init; } = null; + /// /// Block-read coalescing budget (#143). When non-zero, the read planner combines tags /// in the same (UnitId, Region) group whose addresses are at most this many registers diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCoalescingAutoRecoveryTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCoalescingAutoRecoveryTests.cs index 5e03a8a..895749a 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCoalescingAutoRecoveryTests.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCoalescingAutoRecoveryTests.cs @@ -104,6 +104,58 @@ public sealed class ModbusCoalescingAutoRecoveryTests 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 Tags_Outside_Prohibited_Range_Still_Coalesce() {