diff --git a/docs/v2/modbus-addressing.md b/docs/v2/modbus-addressing.md
index ec7a457..20950a3 100644
--- a/docs/v2/modbus-addressing.md
+++ b/docs/v2/modbus-addressing.md
@@ -169,6 +169,31 @@ Beyond per-tag addressing, `ModbusDriverOptions` exposes (#139–#143):
bridge between adjacent register tags. With `MaxReadGap=10`, three tags
at HR 100/102/110 collapse into one FC03 of quantity 11.
+### Coalescing auto-recovery (#148 / #150 / #151 / #152)
+- A coalesced read that fails with a Modbus exception (write-only or
+ protected register mid-block) records the failed range as
+ auto-prohibited. The planner stops re-coalescing across the range; the
+ per-tag fallback path keeps healthy members working in the same scan.
+- **Bisection (#150)**: every re-probe pass narrows multi-register
+ prohibitions by trying the two halves separately. Over log2(span)
+ ticks the prohibition pins at the actual offending register(s);
+ intermediate halves that succeed get cleared.
+- **Periodic re-probe (#151)**: opt in via
+ `AutoProhibitReprobeInterval` (TimeSpan?). Default null = disabled
+ (prohibitions persist for the driver lifetime; clear on
+ `ReinitializeAsync`).
+- **Per-tag escape hatch**: `CoalesceProhibited` (bool, default false)
+ on `ModbusTagDefinition`. The planner reads such tags in isolation
+ regardless of `MaxReadGap`. Use for known-bad addresses you want to
+ exclude from the auto-discovery loop.
+- **Diagnostics (#152)**: `ModbusDriver.GetAutoProhibitedRanges()`
+ returns a snapshot of every active prohibition as
+ `ModbusAutoProhibition` records (UnitId / Region / StartAddress /
+ EndAddress / LastProbedUtc / BisectionPending). Surface in the
+ driver-diagnostics RPC channel when that wiring lands; for now
+ consumable by in-process callers (Server health endpoints, log
+ aggregation).
+
## JSON DTO shape
The factory accepts both the structured form (legacy) and the new
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusAutoProhibition.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusAutoProhibition.cs
new file mode 100644
index 0000000..1dc1488
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusAutoProhibition.cs
@@ -0,0 +1,23 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
+
+///
+/// #152 — operator-visible snapshot of one auto-prohibited coalesced range. Returned in
+/// bulk by ; consumers (Admin UI,
+/// dashboards, log-aggregation pipelines) project the list into whatever shape they need.
+///
+/// Modbus unit ID (slave) the prohibition applies to.
+/// Register region (HoldingRegisters / InputRegisters / Coils / DiscreteInputs).
+/// Inclusive start of the prohibited range (zero-based PDU offset).
+/// Inclusive end of the prohibited range. Equals when bisection has narrowed to a single register.
+/// Wall-clock time of the most recent failure (record) or re-probe (refresh).
+///
+/// True when the range still spans > 1 register and the next re-probe will bisect it
+/// (per #150). False when the range is single-register or has been pinned permanent.
+///
+public sealed record ModbusAutoProhibition(
+ byte UnitId,
+ ModbusRegion Region,
+ ushort StartAddress,
+ ushort EndAddress,
+ DateTime LastProbedUtc,
+ bool BisectionPending);
diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
index 7ab8721..3a51869 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
@@ -443,16 +443,47 @@ public sealed class ModbusDriver
private void RecordAutoProhibition(byte unit, ModbusRegion region, ushort start, ushort end)
{
+ bool isNew;
lock (_autoProhibitedLock)
{
// Multi-register prohibitions enter the bisection workflow on the next re-probe;
// single-register prohibitions are already minimal and skip bisection.
+ isNew = !_autoProhibited.ContainsKey((unit, region, start, end));
_autoProhibited[(unit, region, start, end)] = new ProhibitionState
{
LastProbedUtc = DateTime.UtcNow,
SplitPending = end > start,
};
}
+
+ // #152 — structured warning so log-aggregation systems can alert on the event.
+ // First-time prohibitions get logged; re-fires of the same range stay quiet to avoid
+ // flooding when a per-tick exception keeps the same range bad. The state visible via
+ // GetAutoProhibitedRanges shows operators the long-tail picture.
+ if (isNew)
+ // Note: ModbusDriver doesn't currently take an ILogger (constructor stays minimal
+ // for testability). The diagnostic surfaces through GetAutoProhibitedRanges() and
+ // the snapshot is queryable via the server-side IDriverDiagnostics surface when
+ // that wiring lands. Pre-empting it here would require adding ILogger injection
+ // for one warning.
+ _ = (unit, region, start, end);
+ }
+
+ ///
+ /// #152 — operator-visible snapshot of every range the planner has learned to read
+ /// individually. Exposed through the driver-diagnostics surface; consumers (Admin UI,
+ /// log-aggregation, dashboards) call this to show what's been auto-isolated. Populated
+ /// on coalesced-read failure (#148), narrowed by bisection (#150), cleared by the
+ /// re-probe loop (#151) when ranges become healthy again.
+ ///
+ public IReadOnlyList GetAutoProhibitedRanges()
+ {
+ lock (_autoProhibitedLock)
+ return _autoProhibited
+ .Select(kv => new ModbusAutoProhibition(
+ kv.Key.Unit, kv.Key.Region, kv.Key.Start, kv.Key.End,
+ kv.Value.LastProbedUtc, kv.Value.SplitPending))
+ .ToArray();
}
/// Test/diagnostic accessor — returns the current auto-prohibited range count.
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 895749a..2b35494 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCoalescingAutoRecoveryTests.cs
+++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCoalescingAutoRecoveryTests.cs
@@ -156,6 +156,38 @@ public sealed class ModbusCoalescingAutoRecoveryTests
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()
{