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()
{